Adopt b2b.next's client patterns: services, page state, feedback

Ported from the owner's b2b.next and adapted from MudBlazor to the daisyUI
components:
- LocalizableComponentBase provides CascadingState, Navigation, Toast,
  IsLoading/IsDefaultDisabled, WhenOnline/StartOnlinePolling, and after-render
  jobs that actually run.
- CascadingState starts with initialised state and polls the online status
  with a PeriodicTimer. It reports errors through ILoggingService plus a toast,
  and disposes what it holds.
- New pieces:
  - ToastService + ToastHost, the snackbar, with AddResponseError/Success/
    Warning/Exception in SUtility;
  - ErrorViewer, which shows the validation messages plus AdditionalError(s);
  - LoggingService, which writes to the console and the IndexedDB client log.
- HttpService is rebuilt and registered:
  - auth levels Get/Post/Delete, *Anon and *TotallyAnon;
  - retry with backoff;
  - classified failures (FailureCodes) instead of exceptions;
  - HTTP 410 on cancel;
  - the "default" client.
  ReadWebResult and DefaultReadFromJsonAsync read its responses.
- ClientJsonContext is source-generated, and SUtility.DefaultSerializer and
  Blazored.LocalStorage use it.
- WebResult gains a C# 14 IsInvalid extension property.

The code follows the imported rules:
- Every service and async helper takes a CancellationToken last.
- Primary constructors replace constructor-plus-fields.
- MessagesService and IStorage return Task<WebResult> / Task<List<T>>.
  MessagesService is still mock, building its results in memory; the seed
  feed and thread moved to Faker.
- Index and ExpandMessage follow the page pattern: an offline guard with
  WhenOnline, LoadData, and early returns with toasts on the results they used
  to ignore. ExpandMessage reloads on route change and shows "not found"
  instead of a null message.
- Model and state collections are [], and the fake identity defaults moved
  out of the models.
- Components:
  - no ?. chains over initialised state;
  - [Inject] instead of @inject;
  - IsDefaultDisabled on the actions;
  - autocomplete on every text input;
  - Localizer[key, args] instead of string.Format;
  - the localizer no longer leaks its inner _pLocalizer.
- Settings → General gains a language picker (English/Italiano), and
  SetDefaultCulture limits the culture to those two.

Bugs fixed along the way:
- A compose with untouched (null) content passed the empty check.
- File errors were joined with <br/> and rendered as markup.
- ClientLogs.ErrorMessage() threw on warnings.
- CoalescingStringLocalizer[key, args] showed a raw "{0}" for missing keys.
- AppStatusService exposed a call to a JS function that doesn't exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 15:26:21 +02:00
1 parent ca060f25fb
commit 95428430c0
55 files changed
+1755 -1248

No files matched your search

+7 -3
View File
@@ -10,18 +10,22 @@
} }
else else
{ {
<p role="alert">You are not authorized to access this resource.</p> <p role="alert">@Localizer["You are not authorized to access this resource."]</p>
} }
</NotAuthorized> </NotAuthorized>
</AuthorizeRouteView> </AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="a" /> <FocusOnNavigate RouteData="@routeData" Selector="a" />
</Found> </Found>
<NotFound> <NotFound>
<Title>Not found</Title> <Title>@Localizer["Not found"]</Title>
<LayoutView Layout="@typeof(MainLayout)"> <LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p> <p role="alert">@Localizer["Sorry, there's nothing at this address."]</p>
</LayoutView> </LayoutView>
</NotFound> </NotFound>
</Router> </Router>
</CascadingState> </CascadingState>
</CascadingAuthenticationState> </CascadingAuthenticationState>
@code {
[Inject] CoalescingStringLocalizer Localizer { get; set; }
}
+36
View File
@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
using decePubClient.Models;
using PrivaPub.ClientModels;
namespace decePubClient;
/// <summary>
/// Source-generated JSON metadata for the client's own types, used first by SUtility.DefaultSerializer (HTTP) and by
/// Blazored.LocalStorage (Program.cs). Register every type that crosses HTTP or is stored in localStorage here; types
/// missing from it still work through the reflection fallback. Names stay camelCase: wwwroot/js/theme.js reads
/// PublicCacheData.pageSettings straight from localStorage.
/// </summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IgnoreReadOnlyProperties = true)]
[JsonSerializable(typeof(PublicCacheData))]
[JsonSerializable(typeof(PageSettings))]
[JsonSerializable(typeof(AuthData))]
[JsonSerializable(typeof(User))]
[JsonSerializable(typeof(UserClaim))]
[JsonSerializable(typeof(ActionBarFilter))]
[JsonSerializable(typeof(Message))]
[JsonSerializable(typeof(List<Message>))]
[JsonSerializable(typeof(MessageUser))]
[JsonSerializable(typeof(BoostingUser))]
[JsonSerializable(typeof(Media))]
[JsonSerializable(typeof(MessageForm))]
[JsonSerializable(typeof(UploadMedia))]
[JsonSerializable(typeof(ClientLogs))]
[JsonSerializable(typeof(ClientLogException))]
[JsonSerializable(typeof(ValidationErrors))]
[JsonSerializable(typeof(WebResult))]
public partial class ClientJsonContext : JsonSerializerContext
{
}
+10 -5
View File
@@ -65,33 +65,38 @@
</section> </section>
@code { @code {
[CascadingParameter] CascadingState CascadingState { get; set; }
[Parameter] public EventCallback<MessageForm> OnMessageSubmit { get; set; } [Parameter] public EventCallback<MessageForm> OnMessageSubmit { get; set; }
[Parameter] public EventCallback<TimelineType> OnTimelineChanged { get; set; } [Parameter] public EventCallback<TimelineType> OnTimelineChanged { get; set; }
[Parameter] public EventCallback<TimeSortingType> OnTimeSortingChanged { get; set; } [Parameter] public EventCallback<TimeSortingType> OnTimeSortingChanged { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; } [Inject] ILocalStorageService LocalStorage { get; set; }
bool isPosting { get; set; } = false; bool isPosting;
ActionBarFilter Filters { get; set; } = new(); ActionBarFilter Filters { get; set; } = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter)); var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter));
if (filters == default) IsLoading = false;
await LocalStorage.SetItemAsync(nameof(ActionBarFilter), Filters); if (filters is not null)
else {
Filters = filters; Filters = filters;
return;
}
await LocalStorage.SetItemAsync(nameof(ActionBarFilter), Filters);
} }
void OpenCloseMessageForm() void OpenCloseMessageForm()
{ {
isPosting = !isPosting; isPosting = !isPosting;
} }
async Task OnTimelineChange(TimelineType timelineType) async Task OnTimelineChange(TimelineType timelineType)
{ {
Filters.TimelineType = timelineType; Filters.TimelineType = timelineType;
await LocalStorage.SetItemAsync(nameof(ActionBarFilter), Filters); await LocalStorage.SetItemAsync(nameof(ActionBarFilter), Filters);
await OnTimelineChanged.InvokeAsync(Filters.TimelineType); await OnTimelineChanged.InvokeAsync(Filters.TimelineType);
} }
async Task OnTimeSortChange(TimeSortingType timeSorting) async Task OnTimeSortChange(TimeSortingType timeSorting)
{ {
Filters.TimeSortingType = timeSorting; Filters.TimeSortingType = timeSorting;
+15 -25
View File
@@ -3,7 +3,7 @@
<div class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"> <div class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer">
<div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center" <div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
style="background-image:linear-gradient(to left, var(--color-base-100), transparent), url('@(Message.User?.BackgroundUrl)');"> style="background-image:linear-gradient(to left, var(--color-base-100), transparent), url('@(Message.User.BackgroundUrl)');">
<a class="block h-12 w-12 md:h-16 md:w-16" href="@Message.User.ProfileUrl" title="@Message.User.UserName"> <a class="block h-12 w-12 md:h-16 md:w-16" href="@Message.User.ProfileUrl" title="@Message.User.UserName">
<img alt="@Message.User.UserName" class="h-12 w-12 md:h-16 md:w-16 object-cover rounded-full neo-raised neo-2xs" src="@(Message.User.PictureUrl ?? "/imgs/icon-192.png")" /> <img alt="@Message.User.UserName" class="h-12 w-12 md:h-16 md:w-16 object-cover rounded-full neo-raised neo-2xs" src="@(Message.User.PictureUrl ?? "/imgs/icon-192.png")" />
@@ -32,8 +32,8 @@
</a> </a>
</span> </span>
<span class="flex-none inline-flex gap-1 items-center"> <span class="flex-none inline-flex gap-1 items-center">
<span title="@Message.CreatedAt.ToLocalTime().ToString("📅dd/MM/yy 🕒HH:mm")"> <span title="@Message.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Message.CreatedAt.GetPassedTime(Localizer._pLocalizer) @Message.CreatedAt.GetPassedTime(Localizer)
</span> </span>
<i aria-hidden="true" class="@Message.MessageType.GetMessageTypeIcon() text-base" <i aria-hidden="true" class="@Message.MessageType.GetMessageTypeIcon() text-base"
title="@Localizer[Message.MessageType.ToString()]"> title="@Localizer[Message.MessageType.ToString()]">
@@ -104,13 +104,13 @@
<div class="flex gap-3"> <div class="flex gap-3">
@if (OnMessageReply.HasDelegate) @if (OnMessageReply.HasDelegate)
{ {
<DButton Size="DSize.Sm" Active="isAnswering" OnClick="Reply" title="@Localizer[isAnswering ? "Close" : "Reply"]"> <DButton Size="DSize.Sm" Active="isAnswering" Disabled="IsDefaultDisabled" OnClick="Reply" title="@Localizer[isAnswering ? "Close" : "Reply"]">
<i aria-hidden="true" class="@SUtility.IfTrueThen(isAnswering, "ion-md-close-circle", "ion-md-text") text-lg text-success"></i> <i aria-hidden="true" class="@SUtility.IfTrueThen(isAnswering, "ion-md-close-circle", "ion-md-text") text-lg text-success"></i>
</DButton> </DButton>
} }
@if (OnMessageBoost.HasDelegate) @if (OnMessageBoost.HasDelegate)
{ {
<DButton Size="DSize.Sm" Active="Message.IsBoostedByCurrentUser" OnClick="() => OnMessageBoost.InvokeAsync(Message)" <DButton Size="DSize.Sm" Active="Message.IsBoostedByCurrentUser" Disabled="IsDefaultDisabled" OnClick="() => OnMessageBoost.InvokeAsync(Message)"
title="@Localizer["Boost"]"> title="@Localizer["Boost"]">
<i aria-hidden="true" class="ion-md-repeat @SUtility.IfTrueThen(Message.BoostsCounter != 0, null, "text-lg") text-info"></i> <i aria-hidden="true" class="ion-md-repeat @SUtility.IfTrueThen(Message.BoostsCounter != 0, null, "text-lg") text-info"></i>
@if (Message.BoostsCounter != 0) @if (Message.BoostsCounter != 0)
@@ -121,7 +121,7 @@
} }
@if (OnMessageFavourite.HasDelegate) @if (OnMessageFavourite.HasDelegate)
{ {
<DButton Size="DSize.Sm" Active="Message.IsFavourite" OnClick="() => OnMessageFavourite.InvokeAsync(Message)" <DButton Size="DSize.Sm" Active="Message.IsFavourite" Disabled="IsDefaultDisabled" OnClick="() => OnMessageFavourite.InvokeAsync(Message)"
title="@Localizer["Favourite"]"> title="@Localizer["Favourite"]">
<i aria-hidden="true" class="@SUtility.IfTrueThen(Message.IsFavourite, "ion-md-heart-dislike", "ion-md-heart") text-lg text-pink-300"></i> <i aria-hidden="true" class="@SUtility.IfTrueThen(Message.IsFavourite, "ion-md-heart-dislike", "ion-md-heart") text-lg text-pink-300"></i>
</DButton> </DButton>
@@ -138,28 +138,29 @@
<ChildContent> <ChildContent>
@if (OnUserDirectMessage.HasDelegate) @if (OnUserDirectMessage.HasDelegate)
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="() => OnUserDirectMessage.InvokeAsync(Message)"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="() => OnUserDirectMessage.InvokeAsync(Message)">
<i aria-hidden="true" class="ion-md-paper-plane text-base text-success"></i> <i aria-hidden="true" class="ion-md-paper-plane text-base text-success"></i>
<span>@Localizer["Direct message"]</span> <span>@Localizer["Direct message"]</span>
</DButton> </DButton>
} }
@if (OnUserSilence.HasDelegate) @if (OnUserSilence.HasDelegate)
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="() => OnUserSilence.InvokeAsync(Message.User)"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="() => OnUserSilence.InvokeAsync(Message.User)">
<i aria-hidden="true" class="ion-md-eye-off text-base drop-shadow-sm text-warning"></i> <i aria-hidden="true" class="ion-md-eye-off text-base drop-shadow-sm text-warning"></i>
<span>@Localizer["Mute"]</span> <span>@Localizer["Mute"]</span>
</DButton> </DButton>
} }
@if (OnUserBlock.HasDelegate) @if (OnUserBlock.HasDelegate)
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="() => OnUserBlock.InvokeAsync(Message.User)"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="() => OnUserBlock.InvokeAsync(Message.User)">
<i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i> <i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i>
<span>@Localizer["Block"]</span> <span>@Localizer["Block"]</span>
</DButton> </DButton>
} }
@if (@*Message.User.UserName == CurrentUserName &&*@ OnMessageDelete.HasDelegate) @* Once signed in for real: only for the author's own messages (Message.User.UserId == CascadingState.User.Id). *@
@if (OnMessageDelete.HasDelegate)
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="DeleteMessage" title="@Localizer["Delete"]"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="DeleteMessage" title="@Localizer["Delete"]">
<i aria-hidden="true" class="ion-md-trash text-lg text-error"></i> <i aria-hidden="true" class="ion-md-trash text-lg text-error"></i>
<span>@Localizer["Delete"]</span> <span>@Localizer["Delete"]</span>
</DButton> </DButton>
@@ -184,8 +185,6 @@
</div> </div>
@code { @code {
[CascadingParameter] Task<AuthenticationState> AuthState { get; set; }
[CascadingParameter] CascadingState CascadingState { get; set; }
[Parameter] public Message Message { get; set; } = new(); [Parameter] public Message Message { get; set; } = new();
[Parameter] public EventCallback<MessageForm> OnMessageReply { get; set; } [Parameter] public EventCallback<MessageForm> OnMessageReply { get; set; }
[Parameter] public EventCallback<Message> OnMessageBoost { get; set; } [Parameter] public EventCallback<Message> OnMessageBoost { get; set; }
@@ -200,15 +199,9 @@
[Parameter] public bool Highlighted { get; set; } [Parameter] public bool Highlighted { get; set; }
[Parameter] public bool IncludeExpand { get; set; } = true; [Parameter] public bool IncludeExpand { get; set; } = true;
bool isAnswering { get; set; } = false; bool isAnswering;
string CurrentUserName protected override void OnInitialized() => IsLoading = false;
{
get
{
return AuthState.Result.User.Identity?.Name;
}
}
async Task DeleteMessage() async Task DeleteMessage()
{ {
@@ -222,11 +215,8 @@
await OnMessageReply.InvokeAsync(messageForm); await OnMessageReply.InvokeAsync(messageForm);
} }
async Task Reply() void Reply()
{ {
await Task.Run(() =>
{
});
isAnswering = !isAnswering; isAnswering = !isAnswering;
} }
} }
@@ -1,4 +1,4 @@
@inherits DComponentBase @inherits DComponentBase
@* daisyUI Accordion: https://daisyui.com/components/accordion/ . Instead of daisyUI's radio group, the open item is an @* daisyUI Accordion: https://daisyui.com/components/accordion/ . Instead of daisyUI's radio group, the open item is an
index in C# (@bind-Active, -1 = none). Each DAccordionItem registers itself through the cascading value and renders a index in C# (@bind-Active, -1 = none). Each DAccordionItem registers itself through the cascading value and renders a
@@ -19,7 +19,7 @@
/// <summary>Join the items into one block (join join-vertical, join-item).</summary> /// <summary>Join the items into one block (join join-vertical, join-item).</summary>
[Parameter] public bool Joined { get; set; } [Parameter] public bool Joined { get; set; }
readonly List<DAccordionItem> items = new(); readonly List<DAccordionItem> items = [];
string CssClass => Cx(Joined ? "join join-vertical rounded-box neo-raised" : "flex flex-col gap-2", Class); string CssClass => Cx(Joined ? "join join-vertical rounded-box neo-raised" : "flex flex-col gap-2", Class);
@@ -18,6 +18,7 @@
@code { @code {
[Parameter] public string Src { get; set; } [Parameter] public string Src { get; set; }
// "" on purpose (the documented exception to "no string defaults"): an empty alt marks a decorative image.
[Parameter] public string Alt { get; set; } = ""; [Parameter] public string Alt { get; set; } = "";
/// <summary>Shown in the frame when there is no Src, e.g. initials with Placeholder.</summary> /// <summary>Shown in the frame when there is no Src, e.g. initials with Placeholder.</summary>
[Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public RenderFragment ChildContent { get; set; }
+3 -3
View File
@@ -1,5 +1,4 @@
@inherits DComponentBase @inherits DComponentBase
@inject NavigationManager NavigationManager
@* daisyUI Carousel: https://daisyui.com/components/carousel/ . A scroll-snap strip of DCarouselItem children; the @* daisyUI Carousel: https://daisyui.com/components/carousel/ . A scroll-snap strip of DCarouselItem children; the
user scrolls or swipes it. Give each item w-full for one slide at a time. ShowNavigation (previous/next on every user scrolls or swipes it. Give each item w-full for one slide at a time. ShowNavigation (previous/next on every
@@ -25,6 +24,7 @@
</CascadingValue> </CascadingValue>
@code { @code {
[Inject] NavigationManager NavigationManager { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DCarouselSnap Snap { get; set; } [Parameter] public DCarouselSnap Snap { get; set; }
[Parameter] public DDirection Direction { get; set; } [Parameter] public DDirection Direction { get; set; }
@@ -36,7 +36,7 @@
[Parameter] public string NextText { get; set; } = "Next slide"; [Parameter] public string NextText { get; set; } = "Next slide";
[Parameter] public string SlideText { get; set; } = "Slide"; [Parameter] public string SlideText { get; set; } = "Slide";
readonly List<DCarouselItem> items = new(); readonly List<DCarouselItem> items = [];
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8]; readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
RenderFragment Strip => @<div @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</div>; RenderFragment Strip => @<div @attributes="AdditionalAttributes" class="@CssClass">@ChildContent</div>;
@@ -16,6 +16,7 @@
@code { @code {
[Parameter] public IEnumerable<string> Images { get; set; } [Parameter] public IEnumerable<string> Images { get; set; }
// "" on purpose (the documented exception to "no string defaults"): an empty alt marks a decorative image.
[Parameter] public string Alt { get; set; } = ""; [Parameter] public string Alt { get; set; } = "";
[Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public RenderFragment ChildContent { get; set; }
} }
+2 -2
View File
@@ -1,4 +1,4 @@
@inherits DComponentBase @inherits DComponentBase
@* daisyUI Tab: https://daisyui.com/components/tab/ . Each DTab child registers itself through the cascading value; @* daisyUI Tab: https://daisyui.com/components/tab/ . Each DTab child registers itself through the cascading value;
DTabs renders the tab buttons and the active tab's content. The active tab is an index (@bind-Active). *@ DTabs renders the tab buttons and the active tab's content. The active tab is an index (@bind-Active). *@
@@ -35,7 +35,7 @@
[Parameter] public bool Bottom { get; set; } [Parameter] public bool Bottom { get; set; }
[Parameter] public string ContentClass { get; set; } = "pt-4"; [Parameter] public string ContentClass { get; set; } = "pt-4";
readonly List<DTab> tabs = new(); readonly List<DTab> tabs = [];
string CssClass => Cx("tabs", VariantClass, SizeClass, Bottom ? "tabs-bottom" : null, Class); string CssClass => Cx("tabs", VariantClass, SizeClass, Bottom ? "tabs-bottom" : null, Class);
+58
View File
@@ -0,0 +1,58 @@
@implements IDisposable
@* In-page errors (b2b.next's WidmannErrorViewer): the validation messages of the cascading EditContext, then
AdditionalError (one message) and AdditionalErrors (a list; never join a list into one string), one <li> each.
Renders nothing when there is nothing to show. Messages are text, not markup: they can contain file names. *@
@if (HasContent)
{
<DAlert Color="@(Severity is ErrorViewerSeverity.Warning ? DAlertColor.Warning : DAlertColor.Error)" Variant="DAlertVariant.Soft"
class="@Cx("text-sm", ContainerClass)">
<ul class="list-inside list-disc">
@foreach (var message in Messages)
{
<li>@message</li>
}
</ul>
</DAlert>
}
@code {
[CascadingParameter] EditContext EditContext { get; set; }
[Parameter] public string ContainerClass { get; set; }
[Parameter] public ErrorViewerSeverity Severity { get; set; }
[Parameter] public string AdditionalError { get; set; }
[Parameter] public IEnumerable<string> AdditionalErrors { get; set; } = [];
EditContext subscribedEditContext;
IEnumerable<string> Messages =>
(EditContext?.GetValidationMessages() ?? [])
.Append(AdditionalError)
.Concat(AdditionalErrors)
.Where(message => !string.IsNullOrEmpty(message));
bool HasContent => Messages.Any();
static string Cx(params string[] classes) => DCx.Join(classes);
protected override void OnParametersSet()
{
if (EditContext == subscribedEditContext)
return;
Unsubscribe();
subscribedEditContext = EditContext;
if (subscribedEditContext is not null)
subscribedEditContext.OnValidationStateChanged += OnValidationStateChanged;
}
void OnValidationStateChanged(object sender, ValidationStateChangedEventArgs e) => _ = InvokeAsync(StateHasChanged);
void Unsubscribe()
{
if (subscribedEditContext is not null)
subscribedEditContext.OnValidationStateChanged -= OnValidationStateChanged;
}
public void Dispose() => Unsubscribe();
}
+7
View File
@@ -0,0 +1,7 @@
namespace decePubClient.Components;
public enum ErrorViewerSeverity
{
Error,
Warning
}
+88 -122
View File
@@ -4,15 +4,15 @@
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<div class="flex flex-col mb-3"> <div class="flex flex-col mb-3">
<DInput TValue="string" class="w-full rounded-t-[1.4rem] rounded-b-lg" maxlength="64" <DInput TValue="string" class="w-full rounded-t-[1.4rem] rounded-b-lg" maxlength="64" autocomplete="off"
placeholder="@Localizer["Title (optional)"]" Value="@MessageForm.Title" placeholder="@Localizer["Title (optional)"]" Value="@MessageForm.Title"
ValueChanged="(v) => OnTitleChanged(v)" ValueChanged="(v) => OnTitleChanged(v)"
ValueExpression="() => MessageForm.Title" /> ValueExpression="() => MessageForm.Title" />
<div class="relative mt-1"> <div class="relative mt-1">
<DTextarea TValue="string" @bind-Value="MessageForm.Content" Immediate="true" <DTextarea TValue="string" @bind-Value="MessageForm.Content" Immediate="true"
class="w-full rounded-b-[1.4rem] rounded-t-lg" maxlength="5000" class="w-full rounded-b-[1.4rem] rounded-t-lg" maxlength="@MaxContentLength" autocomplete="off"
placeholder="@Localizer["oh shit... here we go again"]" rows="3" /> placeholder="@Localizer["oh shit... here we go again"]" rows="3" />
<span class="absolute text-xs opacity-50 right-2 bottom-1">@(MessageForm.Content?.Length ?? 0)/5000</span> <span class="absolute text-xs opacity-50 right-2 bottom-1">@ContentLength/@MaxContentLength</span>
</div> </div>
@if (showPreviewButton && isPreviewOpen && MessageForm.Content is { Length: > 0 }) @if (showPreviewButton && isPreviewOpen && MessageForm.Content is { Length: > 0 })
{ {
@@ -30,10 +30,6 @@
</div> </div>
</div> </div>
} }
<div class="text-error text-sm">
<ValidationMessage For="() => MessageForm.Content" />
@contentError
</div>
</div> </div>
<div class="flex flex-col gap-3 mb-3 @SUtility.IfTrueThen(MessageForm.Media.Count == 0, "hidden")"> <div class="flex flex-col gap-3 mb-3 @SUtility.IfTrueThen(MessageForm.Media.Count == 0, "hidden")">
@@ -51,14 +47,14 @@
<i class="ion-md-image"></i> <b>@media.FileName</b> <i class="ion-md-image"></i> <b>@media.FileName</b>
</p> </p>
<p class="text-xs break-all"> <p class="text-xs break-all">
<i class="ion-md-code"></i> @media.ContentType <i class="ion-md-fitness"></i> @media.Size.GetFileSize(Localizer._pLocalizer) <i class="ion-md-code"></i> @media.ContentType <i class="ion-md-fitness"></i> @media.Size.GetFileSize(Localizer)
</p> </p>
</div> </div>
<DButton Size="DSize.Sm" class="self-start" OnClick="() => RemoveFile(media)"> <DButton Size="DSize.Sm" class="self-start" OnClick="() => RemoveFile(media)">
<i class="ion-md-trash text-base text-red-400"></i> <i class="ion-md-trash text-base text-red-400"></i>
</DButton> </DButton>
</div> </div>
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" <DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off"
placeholder="@Localizer["Alternative text"]" rows="1" /> placeholder="@Localizer["Alternative text"]" rows="1" />
</div> </div>
</div> </div>
@@ -74,7 +70,7 @@
<b>@media.FileName</b> <b>@media.FileName</b>
</p> </p>
<p class="text-xs break-all"> <p class="text-xs break-all">
<i class="ion-md-code"></i> @media.ContentType <i class="ion-md-fitness"></i> @media.Size.GetFileSize(Localizer._pLocalizer) <i class="ion-md-code"></i> @media.ContentType <i class="ion-md-fitness"></i> @media.Size.GetFileSize(Localizer)
</p> </p>
</div> </div>
<DButton Size="DSize.Sm" OnClick="() => RemoveFile(media)"> <DButton Size="DSize.Sm" OnClick="() => RemoveFile(media)">
@@ -92,7 +88,7 @@
<b>@media.FileName</b> <b>@media.FileName</b>
</p> </p>
<p class="text-xs break-all"> <p class="text-xs break-all">
<i class="ion-md-code"></i> @media.ContentType <i class="ion-md-fitness"></i> @media.Size.GetFileSize(Localizer._pLocalizer) <i class="ion-md-code"></i> @media.ContentType <i class="ion-md-fitness"></i> @media.Size.GetFileSize(Localizer)
</p> </p>
<audio controls="controls" class="w-full max-h-8"> <audio controls="controls" class="w-full max-h-8">
<source src="@media.Base64Preview" type="@media.ContentType"> <source src="@media.Base64Preview" type="@media.ContentType">
@@ -108,19 +104,14 @@
</div> </div>
@if (fileInputErrorMessage is { Length: > 0 }) <ErrorViewer ContainerClass="mb-3" AdditionalError="@contentError" AdditionalErrors="fileErrors" />
{
<div class="text-error text-sm p-1 md:p-2 mb-3 rounded-xl neo-inset neo-2xs">
@((MarkupString)fileInputErrorMessage)
</div>
}
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex flex-wrap items-start gap-3"> <div class="flex flex-wrap items-start gap-3">
<DDropdown @bind-IsOpen="MessageForm.IsScopeOptionsOpen" <DDropdown @bind-IsOpen="MessageForm.IsScopeOptionsOpen"
TriggerClass="@(MessageForm.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" TriggerClass="@(MessageForm.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
ContentClass="mt-2 flex gap-2 p-2 rounded-box" ContentClass="mt-2 flex gap-2 p-2 rounded-box"
title="@string.Format(Localizer["Message scope type: {0}"], Localizer[MessageForm.MessageType.ToString()])"> title="@Localizer["Message scope type: {0}", Localizer[MessageForm.MessageType.ToString()]]">
<Trigger> <Trigger>
<i class="@MessageForm.MessageType.GetMessageTypeIcon() text-base"></i> <i class="@MessageForm.MessageType.GetMessageTypeIcon() text-base"></i>
</Trigger> </Trigger>
@@ -180,9 +171,6 @@
} }
</ChildContent> </ChildContent>
</DSelect> </DSelect>
<div class="text-error text-sm">
<ValidationMessage For="() => MessageForm.ContentType" />
</div>
</div> </div>
@if (showPreviewButton) @if (showPreviewButton)
@@ -194,7 +182,7 @@
</div> </div>
<DButton Size="DSize.Sm" Type="submit"> <DButton Size="DSize.Sm" Type="submit" Disabled="IsDefaultDisabled">
<span>@Localizer["Post"]</span> <span>@Localizer["Post"]</span>
<i class="ion-md-send"></i> <i class="ion-md-send"></i>
</DButton> </DButton>
@@ -204,16 +192,46 @@
</EditForm> </EditForm>
@code { @code {
[CascadingParameter] CascadingState CascadingState { get; set; } const int MaxContentLength = 5_000;
[Inject] ILoggingService Logger { get; set; }
[Parameter] public Message AnsweringMessage { get; set; } [Parameter] public Message AnsweringMessage { get; set; }
[Parameter] public EventCallback<MessageForm> OnMessageSubmit { get; set; } [Parameter] public EventCallback<MessageForm> OnMessageSubmit { get; set; }
MessageForm MessageForm { get; set; } = new(); MessageForm MessageForm { get; set; } = new();
int totalCharacters { get; set; } = 0; List<string> fileErrors = [];
string fileInputErrorMessage { get; set; } string contentError;
string contentError { get; set; } string acceptedFilesTypes = ".jpg,.jpeg,.png,.gif";
string acceptedFilesTypes { get; set; } = ".jpg,.jpeg,.png,.gif"; bool showPreviewButton;
bool showPreviewButton { get; set; } = false; bool isPreviewOpen;
bool isPreviewOpen { get; set; } = false;
// Content is null until the user types: a form starts without text.
int ContentLength => MessageForm.Content?.Length ?? 0;
int MaximumFileCount => MessageForm.MediaType switch
{
MediaType.Images => 5,
MediaType.Video or MediaType.Audio => 1,
MediaType.Documents => 3,
_ => 0
};
long MaximumFileSize => MessageForm.MediaType switch
{
MediaType.Images or MediaType.Documents => 3_145_728,
MediaType.Audio => 5_242_880,
MediaType.Video => 20_971_520,
_ => 0
};
protected override void OnInitialized()
{
IsLoading = false;
if (AnsweringMessage is null)
return;
MessageForm.Title = AnsweringMessage.Title;
MessageForm.RootMessageId = AnsweringMessage.RootMessageId ?? AnsweringMessage.MessageId;
}
void UpdateMessageType(MessageType messageType) void UpdateMessageType(MessageType messageType)
{ {
@@ -221,114 +239,68 @@
MessageForm.IsScopeOptionsOpen = false; MessageForm.IsScopeOptionsOpen = false;
} }
protected override void OnInitialized() bool ShouldDisableUpload() => MessageForm.Media.Count >= MaximumFileCount;
{
if (AnsweringMessage != default)
{
MessageForm.Title = AnsweringMessage.Title;
MessageForm.RootMessageId = AnsweringMessage.RootMessageId ?? AnsweringMessage.MessageId;
}
}
bool ShouldDisableUpload() bool ShouldHaveMultipleUpload() => MessageForm.MediaType is MediaType.Images or MediaType.Documents;
{
switch (MessageForm.MediaType)
{
case MediaType.Images:
return MessageForm.Media.Count == 5;
case MediaType.Video:
case MediaType.Audio:
return MessageForm.Media.Count == 1;
case MediaType.Documents:
return MessageForm.Media.Count == 3;
default:
return true;
}
}
bool ShouldHaveMultipleUpload()
{
return MessageForm.MediaType is MediaType.Images or MediaType.Documents;
}
async Task OnFileChange(InputFileChangeEventArgs eventArgs) async Task OnFileChange(InputFileChangeEventArgs eventArgs)
{ {
try fileErrors = [];
{ var maximumFileCount = MaximumFileCount;
fileInputErrorMessage = string.Empty;
var maximumFileCount = MessageForm.MediaType switch
{
MediaType.Images => 5,
MediaType.Audio => 1,
MediaType.Video => 1,
MediaType.Documents => 3,
_ => 0
};
if (eventArgs.FileCount > maximumFileCount)
{
fileInputErrorMessage = string.Format(Localizer["The maximum number of files accepted is {0}, but {1} were supplied."], maximumFileCount, eventArgs.FileCount);
return;
}
if (eventArgs.FileCount + MessageForm.Media.Count > maximumFileCount) if (eventArgs.FileCount + MessageForm.Media.Count > maximumFileCount)
{ {
fileInputErrorMessage = string.Format(Localizer["The maximum number of files accepted is {0}, but {1} were supplied."], maximumFileCount, $"{MessageForm.Media.Count}+{eventArgs.FileCount}"); fileErrors.Add(Localizer["The maximum number of files accepted is {0}, but {1} were supplied.", maximumFileCount,
MessageForm.Media.Count + eventArgs.FileCount]);
return; return;
} }
var maxAllowedSize = MessageForm.MediaType switch
{ var maximumFileSize = MaximumFileSize;
MediaType.Images => 3_145_728,
MediaType.Audio => 5_242_880,
MediaType.Video => 20_971_520,
MediaType.Documents => 3_145_728,
_ => 0
};
var uploadMedia = default(UploadMedia);
using (var memStream = new MemoryStream())
foreach (var file in eventArgs.GetMultipleFiles(maximumFileCount)) foreach (var file in eventArgs.GetMultipleFiles(maximumFileCount))
{ {
if (file.Name == default || file.ContentType == default) continue; if (file.Name == default || file.ContentType == default || MessageForm.Media.Any(m => m.FileName == file.Name))
if (MessageForm.Media.Any(m => m.FileName == file.Name)) continue; continue;
if (file.Size > maxAllowedSize)
if (file.Size > maximumFileSize)
{ {
fileInputErrorMessage += string.Format(Localizer["Supplied file \"{0}\" with size {1:N0}MBs exceeds the maximum of {2:N0}MBs."], file.Name, file.Size / 1_048_576, maxAllowedSize / 1_048_576) + "<br/>"; fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
file.Size.GetFileSize(Localizer), maximumFileSize.GetFileSize(Localizer)]);
continue; continue;
} }
uploadMedia = new() var uploadMedia = await ReadFile(file, maximumFileSize);
if (uploadMedia is null)
{ {
FileName = file.Name, fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
ContentType = file.ContentType,
Size = file.Size
};
try
{
using (var imgStream = file.OpenReadStream(maxAllowedSize))
{
await imgStream.CopyToAsync(memStream);
memStream.Position = 0;
uploadMedia.Blob = memStream.ToArray();
await memStream.FlushAsync();
}
}
catch (IOException e)
{
fileInputErrorMessage = e.Message;
continue; continue;
} }
catch (Exception e)
{
fileInputErrorMessage = e.Message;
continue;
}
if (MessageForm.MediaType is MediaType.Images or MediaType.Audio)
uploadMedia.Base64Preview = $"data:{uploadMedia.ContentType};base64,{Convert.ToBase64String(uploadMedia.Blob)}";
MessageForm.Media.Add(uploadMedia); MessageForm.Media.Add(uploadMedia);
} }
} }
catch (Exception e)
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
async Task<UploadMedia> ReadFile(IBrowserFile file, long maximumFileSize, CancellationToken cancellationToken = default)
{ {
fileInputErrorMessage = e.Message; try
{
using var memoryStream = new MemoryStream();
await using var fileStream = file.OpenReadStream(maximumFileSize, cancellationToken);
await fileStream.CopyToAsync(memoryStream, cancellationToken);
var uploadMedia = new UploadMedia
{
FileName = file.Name,
ContentType = file.ContentType,
Size = file.Size,
Blob = memoryStream.ToArray()
};
if (MessageForm.MediaType is MediaType.Images or MediaType.Audio)
uploadMedia.Base64Preview = $"data:{uploadMedia.ContentType};base64,{Convert.ToBase64String(uploadMedia.Blob)}";
return uploadMedia;
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(MessageUpsertForm)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
return default;
} }
} }
@@ -342,12 +314,6 @@
MessageForm.Title = value; MessageForm.Title = value;
} }
void ContentLengthChanged()
{
totalCharacters = MessageForm.Content?.Length ?? 0;
StateHasChanged();
}
void OnContentTypeChanged(ContentType contentType) void OnContentTypeChanged(ContentType contentType)
{ {
MessageForm.ContentType = contentType; MessageForm.ContentType = contentType;
@@ -375,7 +341,7 @@
async Task OnValidSubmit() async Task OnValidSubmit()
{ {
contentError = default; contentError = default;
if ((MessageForm.Content is { Length: 0 } && MessageForm.Media.Count == 0)) if (string.IsNullOrWhiteSpace(MessageForm.Content) && MessageForm.Media.Count == 0)
{ {
contentError = Localizer["Missing content, either message or media"]; contentError = Localizer["Missing content, either message or media"];
return; return;
+28 -28
View File
@@ -11,31 +11,31 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@foreach (var color in Enum.GetValues<DColor>()) @foreach (var color in Enum.GetValues<DColor>())
{ {
<DInput @bind-Value="text" Color="color" placeholder="@color.ToString()" class="w-36" /> <DInput autocomplete="off" @bind-Value="text" Color="color" placeholder="@color.ToString()" class="w-36" />
} }
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<DInput @bind-Value="text" Size="DSize.Xs" placeholder="Xs" class="w-28" /> <DInput autocomplete="off" @bind-Value="text" Size="DSize.Xs" placeholder="Xs" class="w-28" />
<DInput @bind-Value="text" Size="DSize.Sm" placeholder="Sm" class="w-28" /> <DInput autocomplete="off" @bind-Value="text" Size="DSize.Sm" placeholder="Sm" class="w-28" />
<DInput @bind-Value="text" placeholder="Md" class="w-28" /> <DInput autocomplete="off" @bind-Value="text" placeholder="Md" class="w-28" />
<DInput @bind-Value="text" Size="DSize.Lg" placeholder="Lg" class="w-28" /> <DInput autocomplete="off" @bind-Value="text" Size="DSize.Lg" placeholder="Lg" class="w-28" />
<DInput @bind-Value="text" Size="DSize.Xl" placeholder="Xl" class="w-28" /> <DInput autocomplete="off" @bind-Value="text" Size="DSize.Xl" placeholder="Xl" class="w-28" />
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<DInput @bind-Value="text" Ghost="true" placeholder="Ghost" /> <DInput autocomplete="off" @bind-Value="text" Ghost="true" placeholder="Ghost" />
<DInput @bind-Value="text" disabled placeholder="Disabled" /> <DInput autocomplete="off" @bind-Value="text" disabled placeholder="Disabled" />
<DInput @bind-Value="search" Immediate="true" placeholder="Search"> <DInput autocomplete="off" @bind-Value="search" Immediate="true" placeholder="Search">
<Prefix><i class="ion-md-search opacity-50"></i></Prefix> <Prefix><i class="ion-md-search opacity-50"></i></Prefix>
<Suffix><kbd class="kbd kbd-sm">⌘</kbd><kbd class="kbd kbd-sm">K</kbd></Suffix> <Suffix><kbd class="kbd kbd-sm">⌘</kbd><kbd class="kbd kbd-sm">K</kbd></Suffix>
</DInput> </DInput>
<DInput @bind-Value="site" Color="DColor.Primary"> <DInput autocomplete="off" @bind-Value="site" Color="DColor.Primary">
<Prefix><DLabel>https://</DLabel></Prefix> <Prefix><DLabel>https://</DLabel></Prefix>
<Suffix><DLabel>.com</DLabel></Suffix> <Suffix><DLabel>.com</DLabel></Suffix>
</DInput> </DInput>
</div> </div>
<p class="text-sm opacity-60">Immediate search: "@search", text: "@text"</p> <p class="text-sm opacity-60">Immediate search: "@search", text: "@text"</p>
<div class="flex flex-col"> <div class="flex flex-col">
<DInput @bind-Value="model.Name" Validator="true" placeholder="Name (required)" class="w-full max-w-xs" /> <DInput autocomplete="off" @bind-Value="model.Name" Validator="true" placeholder="Name (required)" class="w-full max-w-xs" />
<DValidatorHint>Name is required</DValidatorHint> <DValidatorHint>Name is required</DValidatorHint>
</div> </div>
</section> </section>
@@ -82,22 +82,22 @@
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
@foreach (var color in Enum.GetValues<DColor>()) @foreach (var color in Enum.GetValues<DColor>())
{ {
<DTextarea @bind-Value="bio" Color="color" placeholder="@color.ToString()" class="min-h-0 h-12" /> <DTextarea autocomplete="off" @bind-Value="bio" Color="color" placeholder="@color.ToString()" class="min-h-0 h-12" />
} }
</div> </div>
<div class="flex flex-wrap items-start gap-2"> <div class="flex flex-wrap items-start gap-2">
<DTextarea @bind-Value="bio" Size="DSize.Xs" placeholder="Xs" class="w-24" /> <DTextarea autocomplete="off" @bind-Value="bio" Size="DSize.Xs" placeholder="Xs" class="w-24" />
<DTextarea @bind-Value="bio" Size="DSize.Sm" placeholder="Sm" class="w-24" /> <DTextarea autocomplete="off" @bind-Value="bio" Size="DSize.Sm" placeholder="Sm" class="w-24" />
<DTextarea @bind-Value="bio" placeholder="Md" class="w-24" /> <DTextarea autocomplete="off" @bind-Value="bio" placeholder="Md" class="w-24" />
<DTextarea @bind-Value="bio" Size="DSize.Lg" placeholder="Lg" class="w-24" /> <DTextarea autocomplete="off" @bind-Value="bio" Size="DSize.Lg" placeholder="Lg" class="w-24" />
<DTextarea @bind-Value="bio" Size="DSize.Xl" placeholder="Xl" class="w-24" /> <DTextarea autocomplete="off" @bind-Value="bio" Size="DSize.Xl" placeholder="Xl" class="w-24" />
</div> </div>
<div class="flex flex-wrap items-start gap-2"> <div class="flex flex-wrap items-start gap-2">
<DTextarea @bind-Value="bio" Ghost="true" placeholder="Ghost" /> <DTextarea autocomplete="off" @bind-Value="bio" Ghost="true" placeholder="Ghost" />
<DTextarea @bind-Value="bio" disabled placeholder="Disabled" /> <DTextarea autocomplete="off" @bind-Value="bio" disabled placeholder="Disabled" />
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<DTextarea @bind-Value="model.Bio" Immediate="true" Validator="true" placeholder="Bio, 10 to 140 characters" class="w-full" /> <DTextarea autocomplete="off" @bind-Value="model.Bio" Immediate="true" Validator="true" placeholder="Bio, 10 to 140 characters" class="w-full" />
<DValidatorHint>Between 10 and 140 characters</DValidatorHint> <DValidatorHint>Between 10 and 140 characters</DValidatorHint>
<span class="text-sm opacity-60">@(model.Bio?.Length ?? 0)/140</span> <span class="text-sm opacity-60">@(model.Bio?.Length ?? 0)/140</span>
</div> </div>
@@ -286,21 +286,21 @@
<Legend>Page details</Legend> <Legend>Page details</Legend>
<ChildContent> <ChildContent>
<DLabel>Title</DLabel> <DLabel>Title</DLabel>
<DInput @bind-Value="text" placeholder="My awesome page" /> <DInput autocomplete="off" @bind-Value="text" placeholder="My awesome page" />
<DLabel>Slug</DLabel> <DLabel>Slug</DLabel>
<DInput @bind-Value="site" placeholder="my-awesome-page" /> <DInput autocomplete="off" @bind-Value="site" placeholder="my-awesome-page" />
</ChildContent> </ChildContent>
<Description>You can edit these later</Description> <Description>You can edit these later</Description>
</DFieldset> </DFieldset>
<DFieldset disabled> <DFieldset disabled>
<Legend>Disabled fieldset</Legend> <Legend>Disabled fieldset</Legend>
<ChildContent><DInput @bind-Value="text" placeholder="Can't type here" /></ChildContent> <ChildContent><DInput autocomplete="off" @bind-Value="text" placeholder="Can't type here" /></ChildContent>
</DFieldset> </DFieldset>
<DLabel Floating="true" Text="Your email"> <DLabel Floating="true" Text="Your email">
<DInput @bind-Value="email" placeholder="mail@site.com" /> <DInput autocomplete="off" @bind-Value="email" placeholder="mail@site.com" />
</DLabel> </DLabel>
<DLabel Floating="true" Text="Notes"> <DLabel Floating="true" Text="Notes">
<DTextarea @bind-Value="bio" placeholder="Notes" /> <DTextarea autocomplete="off" @bind-Value="bio" placeholder="Notes" />
</DLabel> </DLabel>
</section> </section>
@@ -308,11 +308,11 @@
<h3 class="text-lg font-bold">DValidator / DValidatorHint</h3> <h3 class="text-lg font-bold">DValidator / DValidatorHint</h3>
<p class="text-sm opacity-60">Native rules, checked by the browser once you've edited the field.</p> <p class="text-sm opacity-60">Native rules, checked by the browser once you've edited the field.</p>
<div class="flex flex-col"> <div class="flex flex-col">
<DInput @bind-Value="email" Type="email" Validator="true" required="true" placeholder="mail@site.com" /> <DInput autocomplete="off" @bind-Value="email" Type="email" Validator="true" required="true" placeholder="mail@site.com" />
<DValidatorHint>Enter a valid email address</DValidatorHint> <DValidatorHint>Enter a valid email address</DValidatorHint>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<DInput @bind-Value="username" Validator="true" required="true" pattern="[A-Za-z][A-Za-z0-9\-]*" minlength="3" <DInput autocomplete="off" @bind-Value="username" Validator="true" required="true" pattern="[A-Za-z][A-Za-z0-9\-]*" minlength="3"
maxlength="30" placeholder="Username" title="Only letters, numbers or dash" /> maxlength="30" placeholder="Username" title="Only letters, numbers or dash" />
<DValidatorHint Compact="true">Must be 3 to 30 letters, numbers or dashes (Compact: no space while valid)</DValidatorHint> <DValidatorHint Compact="true">Must be 3 to 30 letters, numbers or dashes (Compact: no space while valid)</DValidatorHint>
</div> </div>
@@ -375,7 +375,7 @@
string framework; string framework;
DColor? filterColor; DColor? filterColor;
string otp; string otp;
List<string> files = new(); List<string> files = [];
void Submitted(bool valid) => submitResult = valid ? "Valid" : "Invalid: see the messages above"; void Submitted(bool valid) => submitResult = valid ? "Valid" : "Invalid: see the messages above";
+1 -1
View File
@@ -216,7 +216,7 @@
record Toast(int Id, DAlertColor Color); record Toast(int Id, DAlertColor Color);
readonly List<Toast> toasts = new(); readonly List<Toast> toasts = [];
DToastHorizontal toastHorizontal; DToastHorizontal toastHorizontal;
DToastVertical toastVertical; DToastVertical toastVertical;
int toastCount; int toastCount;
+1 -1
View File
@@ -165,7 +165,7 @@
<DButton class="join-item">from lg</DButton> <DButton class="join-item">from lg</DButton>
</DJoin> </DJoin>
<DJoin> <DJoin>
<input class="input join-item" placeholder="Email" /> <input autocomplete="off" class="input join-item" placeholder="Email" />
<select class="select join-item w-28"> <select class="select join-item w-28">
<option>Filter</option> <option>Filter</option>
<option>Sci-fi</option> <option>Sci-fi</option>
+41
View File
@@ -0,0 +1,41 @@
@implements IDisposable
@* Shows ToastService's messages at the bottom end of the screen. Placed once, in MainLayout. *@
@if (Toast.Messages.Count > 0)
{
<DToast class="z-[1000]">
@foreach (var message in Toast.Messages)
{
<DAlert @key="message.Id" Color="ColorOf(message.Severity)" class="max-w-sm">
<ChildContent>
<span class="break-words">@message.Text</span>
</ChildContent>
<Actions>
<DButton Size="DSize.Xs" Shape="DButtonShape.Circle" Variant="DButtonVariant.Ghost" aria-label="@Localizer["Close"]"
OnClick="() => Toast.Close(message.Id)">
<i class="ion-md-close"></i>
</DButton>
</Actions>
</DAlert>
}
</DToast>
}
@code {
[Inject] ToastService Toast { get; set; }
[Inject] CoalescingStringLocalizer Localizer { get; set; }
protected override void OnInitialized() => Toast.Changed += OnChanged;
void OnChanged() => _ = InvokeAsync(StateHasChanged);
static DAlertColor ColorOf(ToastSeverity severity) => severity switch
{
ToastSeverity.Success => DAlertColor.Success,
ToastSeverity.Warning => DAlertColor.Warning,
ToastSeverity.Error => DAlertColor.Error,
_ => DAlertColor.Info
};
public void Dispose() => Toast.Changed -= OnChanged;
}
+83 -28
View File
@@ -2,6 +2,7 @@
using collAnon.Client.Services; using collAnon.Client.Services;
using decePubClient.Helpers;
using decePubClient.Models; using decePubClient.Models;
using decePubClient.Services; using decePubClient.Services;
@@ -16,6 +17,8 @@ using PrivaPub.ClientModels;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.ComponentModel; using System.ComponentModel;
using System.Globalization; using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Web; using System.Web;
@@ -34,52 +37,46 @@ namespace decePubClient.Extensions
public static T QueryString<T>(this NavigationManager navigationManager, string key) public static T QueryString<T>(this NavigationManager navigationManager, string key)
{ {
var value = navigationManager.QueryString()[key]; var value = navigationManager.QueryString()[key];
var converter = TypeDescriptor.GetConverter(typeof(T)); if (!TypeDescriptor.GetConverter(typeof(T)).IsValid(value))
if (converter.IsValid(value))
return (T)Convert.ChangeType(value, typeof(T));
else
return default; return default;
return (T)Convert.ChangeType(value, typeof(T));
} }
#endregion Navigation Manager #endregion Navigation Manager
#region Setups #region Setups
public static async Task SetDefaultCulture(this WebAssemblyHost host) /// <summary>The languages with resources (Resources/AllStrings.*.resx); any other browser language gets English.</summary>
public static readonly string[] SupportedLanguages = ["en", "it"];
public static async Task SetDefaultCulture(this WebAssemblyHost host, CancellationToken cancellationToken = default)
{ {
var culture = default(CultureInfo); var culture = ToCulture(default);
try try
{ {
var storage = host.Services.GetRequiredService<ILocalStorageService>(); var storage = host.Services.GetRequiredService<ILocalStorageService>();
var publicCache = await storage.GetItemAsync<PublicCacheData>(nameof(PublicCacheData)); var publicCache = await storage.GetItemAsync<PublicCacheData>(nameof(PublicCacheData), cancellationToken);
if (publicCache is null) if (publicCache is null)
{ {
var browserLanguage = host.Services.GetRequiredService<AppStatusService>().Language();
publicCache = new(); publicCache = new();
var status = host.Services.GetRequiredService<AppStatusService>(); publicCache.PageSettings.CurrentLanguageCode =
var browserLanguage = status.Language(); SupportedLanguages.FirstOrDefault(language => browserLanguage?.StartsWith(language) ?? false) ?? "en";
if (browserLanguage.Contains("en")) await storage.SetItemAsync(nameof(PublicCacheData), publicCache, cancellationToken);
browserLanguage = "en-GB";
var appConfig = host.Services.GetRequiredService<AppConfiguration>();
if (!string.IsNullOrEmpty(publicCache.PageSettings.CurrentLanguageCode) &&
publicCache.PageSettings.CurrentLanguageCode.Contains("en"))
publicCache.PageSettings.CurrentLanguageCode = "en-GB";
culture = new(publicCache.PageSettings.CurrentLanguageCode ?? browserLanguage);
publicCache.PageSettings.CurrentLanguageCode = culture.TwoLetterISOLanguageName;
await storage.SetItemAsync(nameof(PublicCacheData), publicCache);
} }
culture ??= new(publicCache.PageSettings.CurrentLanguageCode == "en" ? "en-GB" : publicCache.PageSettings.CurrentLanguageCode); culture = ToCulture(publicCache.PageSettings.CurrentLanguageCode);
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine(ex); Console.WriteLine(ex);
culture = new("en-GB"); }
CultureInfo.DefaultThreadCurrentCulture = culture; CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture; CultureInfo.DefaultThreadCurrentUICulture = culture;
} }
}
static CultureInfo ToCulture(string languageCode) => languageCode is "it" ? new("it-IT") : new("en-GB");
#endregion Setups #endregion Setups
@@ -112,6 +109,64 @@ namespace decePubClient.Extensions
public static string ToString(this WebResult webResult, CoalescingStringLocalizer localizer) => public static string ToString(this WebResult webResult, CoalescingStringLocalizer localizer) =>
localizer["{0} - {1}", webResult.StatusCode, webResult.ErrorMessage]; localizer["{0} - {1}", webResult.StatusCode, webResult.ErrorMessage];
/// <summary>
/// Reads a failed response as a WebResult: a failure HttpService made up (FailureHeader) as is, 401/403 and 5xx
/// as a generic message, validation ProblemDetails as their errors, anything else as the server's WebResult.
/// A cancelled request (410) becomes SUtility.DefaultTaskCancelledResult.
/// </summary>
public static async Task<WebResult> ReadWebResult(this HttpContent content, HttpStatusCode statusCode,
CoalescingStringLocalizer localizer, CancellationToken cancellationToken = default)
{
if (statusCode is HttpStatusCode.Gone)
return SUtility.DefaultTaskCancelledResult;
var result = new WebResult();
try
{
var json = await content.ReadAsStringAsync(cancellationToken);
if (content.Headers.Contains(SUtility.FailureHeader))
return JsonSerializer.Deserialize<WebResult>(json, SUtility.DefaultSerializer);
if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
return result.Invalidate(localizer["You are not authorized to do this."], (int)statusCode, FailureCodes.Unauthorized);
if ((int)statusCode > 499)
return result.Invalidate(localizer["The server reported an internal error (HTTP {0}).", (int)statusCode], (int)statusCode,
FailureCodes.ServerError);
if (json.Contains("\"title\""))
{
var validationErrors = JsonSerializer.Deserialize<ValidationErrors>(json, SUtility.DefaultSerializer);
var lines = validationErrors.Errors.Select(error => $"{error.Key}: {string.Join(", ", error.Value)}").Prepend(validationErrors.Title);
return result.Invalidate(string.Join('\n', lines), validationErrors.Status);
}
return JsonSerializer.Deserialize<WebResult>(json, SUtility.DefaultSerializer) ??
result.Invalidate(localizer["The server sent an invalid response."], (int)statusCode);
}
catch (OperationCanceledException)
{
return SUtility.DefaultTaskCancelledResult;
}
catch (Exception ex)
{
return result.Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest, exception: ex);
}
}
/// <summary>Reads a successful response body; a cancelled read returns SUtility.CancellationDefault.</summary>
public static async Task<T> DefaultReadFromJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
{
try
{
return await response.Content.ReadFromJsonAsync<T>(SUtility.DefaultSerializer, cancellationToken);
}
catch (OperationCanceledException)
{
return SUtility.CancellationDefault<T>();
}
}
public static string Timeframe(this DateTime creationDate, CoalescingStringLocalizer localizer) public static string Timeframe(this DateTime creationDate, CoalescingStringLocalizer localizer)
{ {
var timeframe = DateTime.Now - creationDate.ToLocalTime(); var timeframe = DateTime.Now - creationDate.ToLocalTime();
@@ -133,19 +188,19 @@ namespace decePubClient.Extensions
public static AuthorizationPolicy IsAdminPolicy() => public static AuthorizationPolicy IsAdminPolicy() =>
new AuthorizationPolicyBuilder().RequireAuthenticatedUser() new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
.RequireClaim(Policies.IsAdmin, true.ToString().ToLower()) .RequireClaim(Policies.IsAdmin, "true")
.RequireClaim(Policies.IsUser, true.ToString().ToLower()) .RequireClaim(Policies.IsUser, "true")
.RequireClaim(Policies.IsModerator, true.ToString().ToLower()) .RequireClaim(Policies.IsModerator, "true")
.Build(); .Build();
public static AuthorizationPolicy IsUserPolicy() => public static AuthorizationPolicy IsUserPolicy() =>
new AuthorizationPolicyBuilder().RequireAuthenticatedUser() new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
.RequireClaim(Policies.IsUser, true.ToString().ToLower()) .RequireClaim(Policies.IsUser, "true")
.Build(); .Build();
public static AuthorizationPolicy IsUserModeratorPolicy() => public static AuthorizationPolicy IsUserModeratorPolicy() =>
new AuthorizationPolicyBuilder().RequireAuthenticatedUser() new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
.RequireClaim(Policies.IsModerator, true.ToString().ToLower()) .RequireClaim(Policies.IsModerator, "true")
.Build(); .Build();
#endregion #endregion
+12 -21
View File
@@ -1,4 +1,5 @@
using System.Collections.Specialized; using collAnon.Client.Services;
using System.Collections.Specialized;
using System.ComponentModel; using System.ComponentModel;
using System.Globalization; using System.Globalization;
using System.Reflection; using System.Reflection;
@@ -56,32 +57,22 @@ public static class GenericExtensions
}); });
} }
public static string GetPassedTime(this DateTime dateTime, IStringLocalizer localizer) public static string GetPassedTime(this DateTime dateTime, CoalescingStringLocalizer localizer)
{ {
var timeframe = DateTime.Now - dateTime.ToLocalTime(); var timeframe = DateTime.Now - dateTime.ToLocalTime();
switch ((int)timeframe.TotalHours) return (int)timeframe.TotalHours switch
{ {
case >= 24: >= 24 => localizer["{0}d", (int)timeframe.TotalDays],
return string.Format(localizer["{0}d"], (int)timeframe.TotalDays); >= 1 => localizer["{0}h", (int)timeframe.TotalHours],
case >= 1 and < 24: _ => localizer["{0}m", (int)timeframe.TotalMinutes]
return string.Format(localizer["{0}h"], (int)timeframe.TotalHours); };
case 0:
return string.Format(localizer["{0}m"], (int)timeframe.TotalMinutes);
default:
return default;
}
} }
public static string GetFileSize(this long size, IStringLocalizer<AllStrings> localizer) public static string GetFileSize(this long size, CoalescingStringLocalizer localizer) => size switch
{ {
switch (size) >= 1_048_576 => localizer["{0}MB", size / 1_048_576],
{ _ => localizer["{0}KB", size / 1_024]
case >= 1_048_576: };
return string.Format(localizer["{0:N0}MB"], size / 1_048_576);
case < 1_048_576:
return string.Format(localizer["{0}KB"], size / 1_024);
}
}
public static string ParseContent(this string content, ContentType contentType) public static string ParseContent(this string content, ContentType contentType)
{ {
+12
View File
@@ -0,0 +1,12 @@
using PrivaPub.ClientModels;
namespace decePubClient.Extensions;
public static class WebResultExtensions
{
extension(WebResult result)
{
/// <summary>The early-return guard (b2b.next's WebResult.IsInvalid, which PrivaPub's WebResult lacks).</summary>
public bool IsInvalid => !result.IsValid;
}
}
+253 -3
View File
@@ -1,12 +1,29 @@
using decePubClient.Models; using decePubClient.Models;
using decePubClient.Models.Types;
using Markdig;
namespace decePubClient.Helpers; namespace decePubClient.Helpers;
/// <summary>
/// Mock data until the app is wired to PrivaPub: the signed-in user, the feed shown while IndexedDB holds no messages,
/// and the thread around an expanded message.
/// </summary>
public static class Faker public static class Faker
{ {
static IReadOnlyList<MessageUser> Users => new List<MessageUser> public static User CurrentUser => new()
{ {
new(), Id = "45f14fa8-c40f-4121-997c-ef2542196a50",
UserName = "@loosy@letsrulethe.world",
DisplayName = "loosy",
PictureUrl = "https://letsrulethe.world/media/ddd91d51-dc12-4b82-86a2-2a28cc959183/L51ZAL4QEL07",
BackgroundUrl = "https://letsrulethe.world/media/9a3cc6c4-1018-4f41-86c7-f3e63e362bf4/EXM8RUK10B0Z",
ProfileUrl = "https://letsrulethe.world/users/loosy"
};
static IReadOnlyList<MessageUser> Users =>
[
CurrentUser.ToMessageUser(),
new() new()
{ {
UserId = "7b5703dc-aee8-46b1-aed2-cd06021a1c0c", UserId = "7b5703dc-aee8-46b1-aed2-cd06021a1c0c",
@@ -25,7 +42,7 @@ public static class Faker
BackgroundUrl = "https://wallup.net/wp-content/uploads/2016/01/225560-nature-landscape-fall-path-forest-mist-morning-trees-leaves-sunlight.jpg", BackgroundUrl = "https://wallup.net/wp-content/uploads/2016/01/225560-nature-landscape-fall-path-forest-mist-morning-trees-leaves-sunlight.jpg",
ProfileUrl = "https://mastodon.uno/web/@valenappi@beta.birdsite.live" ProfileUrl = "https://mastodon.uno/web/@valenappi@beta.birdsite.live"
} }
}; ];
public static MessageUser GetRandomUser() public static MessageUser GetRandomUser()
{ {
@@ -44,4 +61,237 @@ public static class Faker
ProfileUrl = user.ProfileUrl ProfileUrl = user.ProfileUrl
}; };
} }
/// <summary>
/// The seed feed. Two entries share a MessageId on purpose: the second is the first one boosted by someone, which
/// is how the feed shows a boost (a copy of the message with BoostingUser set).
/// </summary>
public static List<Message> SeedMessages()
{
var messages = new List<Message>();
messages.Add(new()
{
RootMessageId = default,
MessageId = "992167EE-8947-4823-B1E2-B4E1019B6974",
User = GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test di messaggio normale senza titolo",
Medias = [],
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "C5307644-4EA8-4B5F-A240-7CFE4DE34741",
User = GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = "sopra la panca la capra canta",
Content = Markdown.ToHtml("test di messaggio con titolo"),
Medias = [],
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "E473D562-D210-4453-93DA-157D4B90B0D8",
User = GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd test di messaggio con overflow del testo",
Medias = [],
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "adf89dc6-0484-430e-a7c9-fcb1179b0c28",
User = GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test video",
Medias =
[
new()
{
FileName = "8252d175e714db88beb8ee0349dbac90405d3d5148e96e0cfd7de3b0876dbb1b.mp4",
Url = "https://ihatebeinga.live/media/8252d175e714db88beb8ee0349dbac90405d3d5148e96e0cfd7de3b0876dbb1b.mp4",
AltText = "https://ihatebeinga.live/media/8252d175e714db88beb8ee0349dbac90405d3d5148e96e0cfd7de3b0876dbb1b.mp4",
ContentType = "video/mp4"
}
],
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "96025b43-5235-44e9-a3fa-98df31edfbfb",
User = GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test immagine",
Medias =
[
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
}
],
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "277ca76b-aaad-4aa6-95fb-25f59e2fddf9",
User = GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test audio",
Medias =
[
new()
{
FileName = "09bfb06d0096975f.mp3",
Url = "https://cdn.masto.host/mastodonuno/media_attachments/files/107/798/288/501/917/857/original/09bfb06d0096975f.mp3",
AltText = "09bfb06d0096975f.mp3",
ContentType = "audio/mp3"
}
],
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
BoostingUser = GetRandomBoostingUser(),
RootMessageId = default,
MessageId = "de64e520-a7d3-4f31-b5a9-ba236f3b146c",
User = GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test immagini",
Medias =
[
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
},
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
},
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
}
],
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0,100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "de64e520-a7d3-4f31-b5a9-ba236f3b146c",
User = GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test immagini",
Medias =
[
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
},
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
},
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
}
],
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0,100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
return messages;
}
/// <summary>The messages before and after an expanded one.</summary>
public static (List<Message> Ancestors, List<Message> Descendants) Thread(Message message) =>
(
[
new()
{
RootMessageId = default,
MessageId = "51C698E3-7C28-4C90-9212-48D5C81DE089",
User = GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = message.Title,
Content = "<p>esempio di messaggio <b>precedente</b> a quello espanso</p>",
Medias = [],
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
}
],
[
new()
{
RootMessageId = default,
MessageId = "0569A1DF-46FC-4485-9F11-F8CDFB794404",
User = GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = message.Title,
Content = "<p>esempio di messaggio <b>successivo</b> a quello espanso</p>",
Medias = [],
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
}
]
);
} }
+90 -6
View File
@@ -1,17 +1,101 @@
using System.Net.Mail; 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 namespace decePubClient.Helpers
{ {
public static class SUtility public static class SUtility
{ {
public static T IfTrueThen<T>(bool conditionIsTrue, T ifTrue, T ifFalse = default) 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 every HTTP payload: web defaults, ClientJsonContext first, reflection after.</summary>
public static JsonSerializerOptions DefaultSerializer { get; } = new(JsonSerializerDefaults.Web)
{ {
if (conditionIsTrue) DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
return ifTrue; TypeInfoResolver = JsonTypeInfoResolver.Combine(ClientJsonContext.Default, new DefaultJsonTypeInfoResolver())
else };
return ifFalse;
/// <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) public static bool TimeBetween(this DateTime datetime, TimeSpan start, TimeSpan end)
{ {
// convert datetime to a TimeSpan // convert datetime to a TimeSpan
+59 -86
View File
@@ -1,138 +1,111 @@
<CascadingValue IsFixed=false Value=this> @implements IAsyncDisposable
@* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved
PublicCacheData with its theme settings, the signed-in user, and whether the app is online, polled every 10 seconds. *@
<CascadingValue IsFixed=false Value=this>
@ChildContent @ChildContent
</CascadingValue> </CascadingValue>
@code { @code {
[Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public RenderFragment ChildContent { get; set; }
[Inject] IStorage DbStorage { get; set; }
[Inject] ILocalStorageService Storage { get; set; } [Inject] ILocalStorageService Storage { get; set; }
[Inject] IJSRuntime JS { get; set; } [Inject] IJSRuntime JS { get; set; }
// [Inject] DataService DataService { get; set; }
[Inject] public AppStatusService Status { get; set; } [Inject] public AppStatusService Status { get; set; }
[Inject] ILogger<CascadingState> Logger { get; set; } [Inject] ILoggingService Logger { get; set; }
[Inject] ToastService Toast { get; set; }
public bool IsOnline { get; set; } = true; public bool IsOnline { get; private set; } = true;
Timer IsOnlineTimer { get; set; } public bool IsOffline => !IsOnline;
public PublicCacheData PublicCacheData { get; set; } public PublicCacheData PublicCacheData { get; private set; } = new();
public User User { get; set; } /// <summary>The signed-in user; Faker's until the login is wired to PrivaPub.</summary>
public User User { get; private set; } = Faker.CurrentUser;
DotNetObjectReference<CascadingState> cascadingStateReference; DotNetObjectReference<CascadingState> cascadingStateReference;
PeriodicTimer onlineTimer;
readonly CancellationTokenSource disposeCancellation = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var cancellationToken = disposeCancellation.Token;
try try
{ {
IsOnlineTimer = new Timer(async _ => await UpdateIsOnline(), new AutoResetEvent(false), 0, 10000);
cascadingStateReference = DotNetObjectReference.Create(this); cascadingStateReference = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("cascadingStateInstanceReference", cascadingStateReference); await JS.InvokeVoidAsync("cascadingStateInstanceReference", cancellationToken, cascadingStateReference);
PublicCacheData = await Storage.GetItemAsync<PublicCacheData>(nameof(PublicCacheData));
if (PublicCacheData == null)
{
PublicCacheData = new();
await UpdatePublicCache(PublicCacheData);
}
else
Status.ApplyTheme(PublicCacheData.PageSettings);
User = new(); var publicCacheData = await Storage.GetItemAsync<PublicCacheData>(nameof(PublicCacheData), cancellationToken);
await UpdatePublicCache(publicCacheData ?? new(), cancellationToken);
IsOnline = await Status.IsOnline(cancellationToken);
onlineTimer = new(TimeSpan.FromSeconds(10));
_ = PollOnline(cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(CascadingState)}.{nameof(OnInitializedAsync)}"); await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(OnInitializedAsync)}", cancellationToken);
} }
} }
public async ValueTask UpdatePublicCache(PublicCacheData publicCacheData) /// <summary>Saves the public cache, applies its theme and re-renders the app.</summary>
{ public async ValueTask UpdatePublicCache(PublicCacheData publicCacheData, CancellationToken cancellationToken = default)
try
{ {
PublicCacheData = publicCacheData; PublicCacheData = publicCacheData;
await Storage.SetItemAsync(nameof(PublicCacheData), PublicCacheData); await Storage.SetItemAsync(nameof(PublicCacheData), PublicCacheData, cancellationToken);
Status.ApplyTheme(PublicCacheData.PageSettings); Status.ApplyTheme(PublicCacheData.PageSettings);
StateHasChanged(); StateHasChanged();
} }
catch (Exception ex)
{
Console.WriteLine($"{nameof(CascadingState)}.{nameof(UpdatePublicCache)}");
Console.WriteLine(ex.ToString());
}
}
/// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache.</summary> /// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache.</summary>
public ValueTask UpdatePageSettings(Action<PageSettings> change) public ValueTask UpdatePageSettings(Action<PageSettings> change, CancellationToken cancellationToken = default)
{ {
change(PublicCacheData.PageSettings); change(PublicCacheData.PageSettings);
return UpdatePublicCache(PublicCacheData); return UpdatePublicCache(PublicCacheData, cancellationToken);
} }
[JSInvokable] [JSInvokable]
public async Task LogFromJs(string message, string where) public async Task LogFromJs(string message, string where) =>
{
try
{
await ProcessError(new(message), where); await ProcessError(new(message), where);
async Task PollOnline(CancellationToken cancellationToken)
{
try
{
while (await onlineTimer.WaitForNextTickAsync(cancellationToken))
{
var isOnline = await Status.IsOnline(cancellationToken);
if (isOnline == IsOnline)
continue;
IsOnline = isOnline;
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(CascadingState)}.{nameof(LogFromJs)}"); await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(PollOnline)}", cancellationToken);
} }
} }
async Task UpdateIsOnline() /// <summary>Logs an unexpected failure and tells the user.</summary>
public async ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default)
{ {
try await Logger.ProcessError(exception, where, cancellationToken);
{ Toast.AddException(exception);
var latestOnlineState = await Status.IsOnline();
//var latestOnlineState = Random.Shared.Next() % 2 == 0;
if (latestOnlineState != IsOnline)
{
IsOnline = latestOnlineState;
StateHasChanged();
}
// else
// {
// var pingIsOnline = await DataService.Ping();
// if (pingIsOnline != IsOnline)
// {
// IsOnline = pingIsOnline;
// StateHasChanged();
// }
// }
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(CascadingState)}.{nameof(UpdateIsOnline)}");
}
} }
public async ValueTask ProcessError(Exception ex, string where) public async ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
{ {
try await Logger.ProcessWarning(message, where, cancellationToken);
{ Toast.AddWarning(message);
await DbStorage.AddLog(ex, where);
Logger.LogError(ex, where);
await Task.Run(() => {
});
}
catch (Exception exception)
{
Logger.LogError(exception, $"{nameof(CascadingState)}.{nameof(ProcessError)}");
}
} }
public async ValueTask ProcessWarning(string message, string where) public async ValueTask DisposeAsync()
{ {
try await disposeCancellation.CancelAsync();
{ disposeCancellation.Dispose();
await DbStorage.AddLog(message, where); onlineTimer?.Dispose();
Logger.LogWarning("{where} - {message}", where, message); cascadingStateReference?.Dispose();
await Task.Run(() => {
});
}
catch (Exception exception)
{
Logger.LogError(exception, $"{nameof(CascadingState)}.{nameof(ProcessWarning)}");
}
} }
} }
+100 -3
View File
@@ -1,12 +1,109 @@
using collAnon.Client.Services; using collAnon.Client.Services;
using decePubClient.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace decePubClient.LayerComponents namespace decePubClient.LayerComponents
{ {
public class LocalizableComponentBase : ComponentBase /// <summary>
/// Base of every page and app component (b2b.next's WidmannComponentBase). It gives them the cascading state, the
/// localizer, navigation and toasts, the loading flag and the disable rule, and the offline helpers: a page that
/// finds the app offline registers its fetch with WhenOnline, starts StartOnlinePolling and stops loading; the
/// job runs once when the app is back online.
/// </summary>
public class LocalizableComponentBase : ComponentBase, IAsyncDisposable
{ {
[CascadingParameter] protected CascadingState CascadingState { get; set; }
[Inject] public CoalescingStringLocalizer Localizer { get; set; } [Inject] public CoalescingStringLocalizer Localizer { get; set; }
protected List<Func<Task>> AfterRenderAsyncJobs = new(); [Inject] protected NavigationManager Navigation { get; set; }
[Inject] protected ToastService Toast { get; set; }
/// <summary>Work to run once after the next render, e.g. JS interop that needs the rendered DOM.</summary>
protected List<Func<Task>> AfterRenderAsyncJobs = [];
/// <summary>True until the component's first load completes; set it to false at the end of OnInitializedAsync.</summary>
protected bool IsLoading { get; set; } = true; protected bool IsLoading { get; set; } = true;
/// <summary>What every Disabled= binding starts from: loading, or no connection.</summary>
protected bool IsDefaultDisabled => IsLoading || CascadingState.IsOffline;
readonly List<Func<Task>> onOnlineJobs = [];
PeriodicTimer pollingTimer;
CancellationTokenSource pollingCancellation;
bool disposed;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (AfterRenderAsyncJobs.Count == 0)
return;
var jobs = AfterRenderAsyncJobs.ToList();
AfterRenderAsyncJobs.Clear();
foreach (var job in jobs)
await job();
}
/// <summary>Runs the job once, the next time the app is online (needs StartOnlinePolling).</summary>
protected void WhenOnline(Func<Task> job) => onOnlineJobs.Add(job);
/// <summary>Checks every interval (10 seconds by default) whether the app is back online to run the WhenOnline jobs.</summary>
protected void StartOnlinePolling(TimeSpan? interval = null)
{
if (pollingTimer is not null)
return;
pollingCancellation = new();
pollingTimer = new(interval ?? TimeSpan.FromSeconds(10));
_ = PollOnline(pollingCancellation.Token);
}
async Task PollOnline(CancellationToken cancellationToken)
{
try
{
while (await pollingTimer.WaitForNextTickAsync(cancellationToken))
{
if (CascadingState.IsOffline || onOnlineJobs.Count == 0)
continue;
var jobs = onOnlineJobs.ToList();
onOnlineJobs.Clear();
foreach (var job in jobs)
{
try
{
await InvokeAsync(job);
}
catch (Exception ex)
{
await CascadingState.ProcessError(ex, $"{GetType().Name}.{nameof(PollOnline)}", cancellationToken);
}
}
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
}
public virtual async ValueTask DisposeAsync()
{
if (disposed)
return;
disposed = true;
if (pollingCancellation is not null)
{
await pollingCancellation.CancelAsync();
pollingCancellation.Dispose();
}
pollingTimer?.Dispose();
onOnlineJobs.Clear();
GC.SuppressFinalize(this);
}
} }
} }
+5 -5
View File
@@ -2,10 +2,10 @@
{ {
public class BoostingUser public class BoostingUser
{ {
public string UserId { get; set; } = "45f14fa8-c40f-4121-997c-ef2542196a50"; public string UserId { get; set; }
public string UserName { get; set; } = "@loosy@letsrulethe.world"; public string UserName { get; set; }
public string DisplayName { get; set; } = "loosy"; public string DisplayName { get; set; }
public string PictureUrl { get; set; } = "https://letsrulethe.world/media/ddd91d51-dc12-4b82-86a2-2a28cc959183/L51ZAL4QEL07"; public string PictureUrl { get; set; }
public string ProfileUrl { get; set; } = "https://letsrulethe.world/users/loosy"; public string ProfileUrl { get; set; }
} }
} }
+9 -11
View File
@@ -7,21 +7,19 @@
public string WarningMessage { get; set; } public string WarningMessage { get; set; }
public ClientLogException Exception { get; set; } public ClientLogException Exception { get; set; }
public DateTime TimeStamp { get; set; } = DateTime.Now; public DateTime TimeStamp { get; set; } = DateTime.Now;
/// <summary>The entry as HTML for a log viewer: the warning text, or the exception's details.</summary>
public string ErrorMessage() public string ErrorMessage()
{ {
if (Exception.InnerExceptionMessage != null) if (Exception is null)
{ return WarningMessage;
return @$"<b>Type</b>: {Exception.Type ?? "N/A"} <b>HelpLink</b>: {Exception.HelpLink ?? "N/A"} <b>HResult</b>: {Exception.HResult ?? "N/A"}<br>
<b>Source</b>: {Exception.Source ?? "N/A"} <b>StackTrace</b>: {Exception.StackTrace ?? "N/A"} <b>MemberName</b>: {Exception.TargetSiteName ?? "N/A"}<br> var details = @$"<b>Type</b>: {Exception.Type ?? "N/A"} <b>HelpLink</b>: {Exception.HelpLink ?? "N/A"} <b>HResult</b>: {Exception.HResult ?? "N/A"}<br>
<b>Message</b>: {Exception.Message ?? "N/A"}<br>
<b>InnerExceptionMessage</b>:{Exception.InnerExceptionMessage ?? "N/A"}";
}
else
{
return @$"<b>Type</b>: {Exception.Type ?? "N/A"} <b>HelpLink</b>: {Exception.HelpLink ?? "N/A"} <b>HResult</b>: {Exception.HResult ?? "N/A"}<br>
<b>Source</b>: {Exception.Source ?? "N/A"} <b>StackTrace</b>: {Exception.StackTrace ?? "N/A"} <b>MemberName</b>: {Exception.TargetSiteName ?? "N/A"}<br> <b>Source</b>: {Exception.Source ?? "N/A"} <b>StackTrace</b>: {Exception.StackTrace ?? "N/A"} <b>MemberName</b>: {Exception.TargetSiteName ?? "N/A"}<br>
<b>Message</b>: {Exception.Message ?? "N/A"}"; <b>Message</b>: {Exception.Message ?? "N/A"}";
} if (Exception.InnerExceptionMessage is null)
return details;
return $"{details}<br>\n<b>InnerExceptionMessage</b>:{Exception.InnerExceptionMessage}";
} }
} }
+20
View File
@@ -0,0 +1,20 @@
namespace decePubClient.Models;
/// <summary>
/// WebResult.ErrorCode values HttpService sets when a request fails before or outside the server, so the UI can tell a
/// connectivity problem (shown as a warning) from a real error.
/// </summary>
public static class FailureCodes
{
public const string Cancelled = "CANCELLED";
public const string DeviceOffline = "DEVICE-OFFLINE";
public const string Timeout = "TIMEOUT";
public const string ApiUnreachable = "API-UNREACHABLE";
public const string RetriesExhausted = "RETRIES-EXHAUSTED";
public const string ServerError = "SERVER-ERROR";
public const string ClientError = "CLIENT-ERROR";
public const string Unauthorized = "UNAUTHORIZED";
public static bool IsConnectivity(string errorCode) =>
errorCode is DeviceOffline or Timeout or ApiUnreachable or RetriesExhausted;
}
+1 -1
View File
@@ -6,6 +6,6 @@
public string Url { get; set; } public string Url { get; set; }
public string ContentType { get; set; } public string ContentType { get; set; }
public string AltText { get; set; } public string AltText { get; set; }
public byte[] Blob { get; set; } //TODO TEMPORARY public byte[] Blob { get; set; } = []; //TODO TEMPORARY
} }
} }
+1 -1
View File
@@ -16,7 +16,7 @@ namespace decePubClient.Models
public int BoostsCounter { get; set; } = 0; public int BoostsCounter { get; set; } = 0;
public bool IsBoostedByCurrentUser { get; set; } = false; public bool IsBoostedByCurrentUser { get; set; } = false;
public BoostingUser BoostingUser { get; set; } public BoostingUser BoostingUser { get; set; }
public List<Media> Medias { get; set; } = new(); public List<Media> Medias { get; set; } = [];
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
[Bindable(false), JsonIgnore] [Bindable(false), JsonIgnore]
+1 -1
View File
@@ -19,7 +19,7 @@ public class MessageForm
public string Title { get; set; } public string Title { get; set; }
[StringLength(5_000, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))] [StringLength(5_000, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
public string Content { get; set; } public string Content { get; set; }
public List<UploadMedia> Media { get; set; } = new(); public List<UploadMedia> Media { get; set; } = [];
[JsonIgnore, Bindable(false)] [JsonIgnore, Bindable(false)]
public MediaType MediaType { get; set; } = MediaType.Images; public MediaType MediaType { get; set; } = MediaType.Images;
+6 -6
View File
@@ -2,11 +2,11 @@
{ {
public class MessageUser public class MessageUser
{ {
public string UserId { get; set; } = "45f14fa8-c40f-4121-997c-ef2542196a50"; public string UserId { get; set; }
public string UserName { get; set; } = "@loosy@letsrulethe.world"; public string UserName { get; set; }
public string DisplayName { get; set; } = "loosy"; public string DisplayName { get; set; }
public string PictureUrl { get; set; } = "https://letsrulethe.world/media/ddd91d51-dc12-4b82-86a2-2a28cc959183/L51ZAL4QEL07"; public string PictureUrl { get; set; }
public string BackgroundUrl { get; set; } = "https://letsrulethe.world/media/9a3cc6c4-1018-4f41-86c7-f3e63e362bf4/EXM8RUK10B0Z"; public string BackgroundUrl { get; set; }
public string ProfileUrl { get; set; } = "https://letsrulethe.world/users/loosy"; public string ProfileUrl { get; set; }
} }
} }
+3 -3
View File
@@ -6,12 +6,12 @@ namespace decePubClient.Models
{ {
public class PublicCacheData public class PublicCacheData
{ {
public IReadOnlyList<ViewLanguage> Languages { get; set; } = new List<ViewLanguage>(); public IReadOnlyList<ViewLanguage> Languages { get; set; } = [];
public List<MediaDeviceInfo> QrCodeDevices { get; set; } = new(); public List<MediaDeviceInfo> QrCodeDevices { get; set; } = [];
public string LastPage { get; set; } public string LastPage { get; set; }
public string CurrentSubPage { get; set; } public string CurrentSubPage { get; set; }
public PageSettings PageSettings { get; set; } = new(); public PageSettings PageSettings { get; set; } = new();
public bool ShowLogs { get; set; } = false; public bool ShowLogs { get; set; } = false;
public IReadOnlyDictionary<string, string> Policies { get; set; } = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>()); public IReadOnlyDictionary<string, string> Policies { get; set; } = ReadOnlyDictionary<string, string>.Empty;
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ public class UploadMedia
public string ContentType { get; set; } public string ContentType { get; set; }
public string FileName { get; set; } public string FileName { get; set; }
public string AltText { get; set; } public string AltText { get; set; }
public byte[] Blob { get; set; } public byte[] Blob { get; set; } = [];
[JsonIgnore, Bindable(false)] [JsonIgnore, Bindable(false)]
public string Base64Preview { get; set; } public string Base64Preview { get; set; }
+26 -7
View File
@@ -2,15 +2,34 @@
{ {
public class User public class User
{ {
public string Id { get; set; } = "45f14fa8-c40f-4121-997c-ef2542196a50"; public string Id { get; set; }
public bool IsAuthenticated { get; set; } = false; public bool IsAuthenticated { get; set; } = false;
public List<UserClaim> Claims { get; set; } = new(); public List<UserClaim> Claims { get; set; } = [];
public string UserName { get; set; } = "@loosy@letsrulethe.world"; public string UserName { get; set; }
public string DisplayName { get; set; } = "loosy"; public string DisplayName { get; set; }
public string PictureUrl { get; set; } = "https://letsrulethe.world/media/ddd91d51-dc12-4b82-86a2-2a28cc959183/L51ZAL4QEL07"; public string PictureUrl { get; set; }
public string BackgroundUrl { get; set; } = "https://letsrulethe.world/media/9a3cc6c4-1018-4f41-86c7-f3e63e362bf4/EXM8RUK10B0Z"; public string BackgroundUrl { get; set; }
public string ProfileUrl { get; set; } = "https://letsrulethe.world/users/loosy"; public string ProfileUrl { get; set; }
public MessageUser ToMessageUser() => new()
{
UserId = Id,
UserName = UserName,
DisplayName = DisplayName,
PictureUrl = PictureUrl,
BackgroundUrl = BackgroundUrl,
ProfileUrl = ProfileUrl
};
public BoostingUser ToBoostingUser() => new()
{
UserId = Id,
UserName = UserName,
DisplayName = DisplayName,
PictureUrl = PictureUrl,
ProfileUrl = ProfileUrl
};
} }
public class UserClaim public class UserClaim
+11
View File
@@ -0,0 +1,11 @@
namespace decePubClient.Models;
/// <summary>ASP.NET Core's validation ProblemDetails, as the server returns it for an invalid request body.</summary>
public class ValidationErrors
{
public string Type { get; set; }
public string Title { get; set; }
public int Status { get; set; }
public Dictionary<string, string[]> Errors { get; set; } = [];
public string TraceId { get; set; }
}
-1
View File
@@ -177,5 +177,4 @@
</section> </section>
@code { @code {
[CascadingParameter] CascadingState CascadingState { get; set; }
} }
+93 -94
View File
@@ -14,7 +14,14 @@
@Localizer["loading"] @Localizer["loading"]
</p> </p>
} }
else if (IsNotFound)
{
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["This message doesn't exist."]
</p>
}
else
{
@foreach (var message in Ancestors) @foreach (var message in Ancestors)
{ {
<Content CssContainer="ml-6 w-auto" IncludeExpand="false" Message="message" <Content CssContainer="ml-6 w-auto" IncludeExpand="false" Message="message"
@@ -38,6 +45,7 @@
OnMessageMediaDownload="async (m) => await OnMessageMediaDownload(m)" OnMessageMediaDownload="async (m) => await OnMessageMediaDownload(m)"
OnUserBlock="async (u) => await OnUserBlock(u)" OnUserDirectMessage="async (u) => await OnUserDirectMessage(u)" OnUserSilence="async (u) => await OnUserSilence(u)" /> OnUserBlock="async (u) => await OnUserBlock(u)" OnUserDirectMessage="async (u) => await OnUserDirectMessage(u)" OnUserSilence="async (u) => await OnUserSilence(u)" />
} }
}
</div> </div>
</section> </section>
@@ -45,146 +53,137 @@
</div> </div>
@code { @code {
[CascadingParameter] CascadingState CascadingState { get; set; }
[Inject] NavigationManager Navigation { get; set; }
[Inject] IStorage DbStorage { get; set; } [Inject] IStorage DbStorage { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; } [Inject] ILocalStorageService LocalStorage { get; set; }
[Inject] IBlazorDownloadFileService BlazorDownloadFileService { get; set; } [Inject] IBlazorDownloadFileService BlazorDownloadFileService { get; set; }
[Inject] MessagesService MessagesService { get; set; } [Inject] MessagesService MessagesService { get; set; }
[Parameter] public string MessageId { get; set; } [Parameter] public string MessageId { get; set; }
List<Message> Ancestors { get; set; } = new(); List<Message> Ancestors { get; set; } = [];
Message CurrentMessage { get; set; } = new(); Message CurrentMessage { get; set; } = new();
List<Message> Descendants { get; set; } = new(); List<Message> Descendants { get; set; } = [];
bool IsNotFound { get; set; }
TimeSortingType TimeSortingType { get; set; } = TimeSortingType.Ascending; TimeSortingType TimeSortingType { get; set; } = TimeSortingType.Ascending;
protected override async Task OnInitializedAsync() // Parameters rather than initialization: following a link to another message reuses this page.
protected override async Task OnParametersSetAsync()
{ {
if (MessageId is { Length: 0 }) if (string.IsNullOrEmpty(MessageId))
{ {
Navigation.NavigateTo("/"); Navigation.NavigateTo("");
return; return;
} }
var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter)); IsLoading = true;
if (filters == default) var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter)) ?? new();
await LocalStorage.SetItemAsync(nameof(ActionBarFilter), new ActionBarFilter());
else
TimeSortingType = filters.TimeSortingType; TimeSortingType = filters.TimeSortingType;
CurrentMessage = await DbStorage.GetMessage(MessageId); if (CascadingState.IsOffline)
Ancestors = new List<Message>
{ {
new() WhenOnline(LoadData);
{ StartOnlinePolling();
RootMessageId = default, IsLoading = false;
MessageId = "51C698E3-7C28-4C90-9212-48D5C81DE089", return;
User = Faker.GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = CurrentMessage?.Title,
Content = "<p>esempio di messaggio <b>precedente</b> a quello espanso</p>",
Medias = new(),
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0,100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
} }
};
Descendants = new List<Message> await LoadData();
{
new()
{
RootMessageId = default,
MessageId = "0569A1DF-46FC-4485-9F11-F8CDFB794404",
User = Faker.GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = CurrentMessage?.Title,
Content = "<p>esempio di messaggio <b>successivo</b> a quello espanso</p>",
Medias = new(),
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0,100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
}
};
IsLoading = false; IsLoading = false;
} }
void OnTimeSortingChanged(TimeSortingType timeSortingType) // The thread: the message from IndexedDB, the ones around it mock (Faker) until the app reads PrivaPub's context.
async Task LoadData()
{ {
TimeSortingType = timeSortingType; var message = await DbStorage.GetMessage(MessageId);
IsNotFound = message is null;
if (IsNotFound)
return;
CurrentMessage = message;
(Ancestors, Descendants) = Faker.Thread(message);
} }
async Task OnMessageReply(MessageForm messageForm) async Task OnMessageReply(MessageForm messageForm)
{ {
var replyMessage = await MessagesService.SubmitMessage(messageForm); var result = await MessagesService.SubmitMessage(messageForm, CascadingState.User);
Descendants.Add(new() if (result.IsInvalid)
{ {
MessageId = Guid.NewGuid().ToString(), Toast.AddResponseError(result);
Content = messageForm.Content?.ParseContent(messageForm.ContentType), return;
CreatedAt = DateTime.UtcNow,
MessageType = messageForm.MessageType,
Medias = messageForm.Media.Select(m => new Media
{
FileName = m.FileName,
AltText = m.AltText,
ContentType = m.ContentType,
Url = m.Base64Preview,
Blob = m.Blob
}).ToList(),
Title = messageForm.Title,
User = new MessageUser
{
UserId = CascadingState.User.Id,
UserName = CascadingState.User.UserName,
DisplayName = CascadingState.User.DisplayName,
PictureUrl = CascadingState.User.PictureUrl,
ProfileUrl = CascadingState.User.ProfileUrl,
BackgroundUrl = CascadingState.User.BackgroundUrl
},
});
} }
async ValueTask OnMessageBoost(Message message) Descendants.Add(result.Data as Message);
{
if (message.IsBoostedByCurrentUser) return;
var boostedMessage = await MessagesService.BoostUnboostMessage(message);
message.BoostsCounter += 1;
//Add to the home page in the background
} }
async ValueTask OnMessageFavourite(Message message) async Task OnMessageBoost(Message message)
{ {
var favouriteMessage = await MessagesService.FavouriteUnfavouriteMessage(message); if (message.IsBoostedByCurrentUser)
message.IsFavourite = !message.IsFavourite; return;
var result = await MessagesService.BoostUnboostMessage(message);
if (result.IsInvalid)
Toast.AddResponseError(result);
} }
async ValueTask OnMessageDelete(Message message) async Task OnMessageFavourite(Message message)
{ {
var deleteResult = await MessagesService.DeleteMessage(message); var result = await MessagesService.FavouriteUnfavouriteMessage(message);
if (result.IsInvalid)
Toast.AddResponseError(result);
} }
async ValueTask OnMessageMediaDownload(Media media) async Task OnMessageDelete(Message message)
{
var result = await MessagesService.DeleteMessage(message);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["Message deleted."]);
if (message == CurrentMessage)
{
Navigation.NavigateTo("");
return;
}
Ancestors.Remove(message);
Descendants.Remove(message);
}
async Task OnMessageMediaDownload(Media media)
{ {
await BlazorDownloadFileService.DownloadFileAsync(media.FileName, media.Blob, media.ContentType); await BlazorDownloadFileService.DownloadFileAsync(media.FileName, media.Blob, media.ContentType);
} }
async ValueTask OnUserBlock(MessageUser messageUser) async Task OnUserBlock(MessageUser messageUser)
{ {
var blockResult = await MessagesService.BlockUserFromMessage(messageUser); var result = await MessagesService.BlockUserFromMessage(messageUser);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
} }
async ValueTask OnUserDirectMessage(Message message) Toast.AddResponseSuccess(Localizer["{0} blocked.", messageUser.DisplayName]);
{
var directMessage = await MessagesService.ReplyMessage(message, MessageType.Direct);
} }
async ValueTask OnUserSilence(MessageUser messageUser) async Task OnUserDirectMessage(Message message)
{ {
var silenceResult = await MessagesService.SilenceUserFromMessage(messageUser); var result = await MessagesService.ReplyMessage(message, MessageType.Direct);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
async Task OnUserSilence(MessageUser messageUser)
{
var result = await MessagesService.SilenceUserFromMessage(messageUser);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["{0} muted.", messageUser.DisplayName]);
} }
} }
+84 -138
View File
@@ -27,7 +27,7 @@
{ {
<Content Message="message" <Content Message="message"
OnMessageBoost="async (m) => await OnMessageBoost(m)" OnMessageDelete="async (m) => await OnMessageDelete(m)" OnMessageBoost="async (m) => await OnMessageBoost(m)" OnMessageDelete="async (m) => await OnMessageDelete(m)"
OnMessageFavourite="async (m) => await OnMessageFavourite(m)" OnMessageReply="async (m) => await OnMessageReply(m)" OnMessageFavourite="async (m) => await OnMessageFavourite(m)" OnMessageReply="async (m) => await OnMessageSubmit(m)"
OnMessageMediaDownload="async (m) => await OnMessageMediaDownload(m)" OnMessageMediaDownload="async (m) => await OnMessageMediaDownload(m)"
OnUserBlock="async (u) => await OnUserBlock(u)" OnUserDirectMessage="async (u) => await OnUserDirectMessage(u)" OnUserSilence="async (u) => await OnUserSilence(u)" /> OnUserBlock="async (u) => await OnUserBlock(u)" OnUserDirectMessage="async (u) => await OnUserDirectMessage(u)" OnUserSilence="async (u) => await OnUserSilence(u)" />
} }
@@ -58,55 +58,39 @@
</div> </div>
@code { @code {
[CascadingParameter] Task<AuthenticationState> AuthState { get; set; }
[CascadingParameter] CascadingState CascadingState { get; set; }
[Inject] IBlazorDownloadFileService BlazorDownloadFileService { get; set; } [Inject] IBlazorDownloadFileService BlazorDownloadFileService { get; set; }
[Inject] IStorage Storage { get; set; } [Inject] IStorage Storage { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; } [Inject] ILocalStorageService LocalStorage { get; set; }
[Inject] HttpClient Http { get; set; }
[Inject] MessagesService MessagesService { get; set; } [Inject] MessagesService MessagesService { get; set; }
TimelineType TimelineType { get; set; } = TimelineType.Home; TimelineType TimelineType { get; set; } = TimelineType.Home;
TimeSortingType TimeSortingType { get; set; } = TimeSortingType.Ascending; TimeSortingType TimeSortingType { get; set; } = TimeSortingType.Ascending;
List<Message> Messages { get; set; } = [];
IReadOnlyList<Claim> userClaim
{
get
{
var auth = AuthState.Result;
if (auth.User.Identity?.IsAuthenticated ?? false)
return auth.User.Claims.ToList();
return new List<Claim>();
}
}
List<Message> IncomingMessages { get; set; } = new();
List<Message> Messages { get; set; } = new();
string Response { get; set; }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter)); var filters = await LocalStorage.GetItemAsync<ActionBarFilter>(nameof(ActionBarFilter)) ?? new();
if (filters == default)
await LocalStorage.SetItemAsync(nameof(ActionBarFilter), new ActionBarFilter());
else
{
TimelineType = filters.TimelineType; TimelineType = filters.TimelineType;
TimeSortingType = filters.TimeSortingType; TimeSortingType = filters.TimeSortingType;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
} }
Messages = await Storage.GetMessages(); await LoadData();
//try
//{
// var response = await Http.GetAsync("api/test");
// Response = await response.Content.ReadAsStringAsync();
//}
//catch
//{
//}
IsLoading = false; IsLoading = false;
} }
// The feed: the mock one in IndexedDB until the app reads PrivaPub's timeline.
async Task LoadData()
{
Messages = await Storage.GetMessages();
}
void OnTimelineChanged(TimelineType timelineType) void OnTimelineChanged(TimelineType timelineType)
{ {
TimelineType = timelineType; TimelineType = timelineType;
@@ -117,90 +101,38 @@
TimeSortingType = timeSortingType; TimeSortingType = timeSortingType;
} }
// Covers replies too: the form carries the RootMessageId.
async Task OnMessageSubmit(MessageForm messageForm) async Task OnMessageSubmit(MessageForm messageForm)
{ {
await Task.Run(() => var result = await MessagesService.SubmitMessage(messageForm, CascadingState.User);
if (result.IsInvalid)
{ {
}); Toast.AddResponseError(result);
var submitMessage = await MessagesService.SubmitMessage(messageForm); return;
Messages.Insert(0, new()
{
MessageId = Guid.NewGuid().ToString(),
Content = messageForm.Content?.ParseContent(messageForm.ContentType),
CreatedAt = DateTime.UtcNow,
MessageType = messageForm.MessageType,
Medias = messageForm.Media.Select(m => new Media
{
FileName = m.FileName,
AltText = m.AltText,
ContentType = m.ContentType,
Url = m.Base64Preview,
Blob = m.Blob
}).ToList(),
Title = messageForm.Title,
User = new MessageUser
{
UserId = CascadingState.User.Id,
UserName = CascadingState.User.UserName,
DisplayName = CascadingState.User.DisplayName,
PictureUrl = CascadingState.User.PictureUrl,
ProfileUrl = CascadingState.User.ProfileUrl,
BackgroundUrl = CascadingState.User.BackgroundUrl
},
});
} }
async Task OnMessageReply(MessageForm messageForm) Messages.Insert(0, result.Data as Message);
{
await Task.Run(() =>
{
});
var replyMessage = await MessagesService.SubmitMessage(messageForm);
Messages.Insert(0, new()
{
RootMessageId = messageForm.RootMessageId,
MessageId = Guid.NewGuid().ToString(),
Content = messageForm.Content?.ParseContent(messageForm.ContentType),
CreatedAt = DateTime.UtcNow,
MessageType = messageForm.MessageType,
Medias = messageForm.Media.Select(m => new Media
{
FileName = m.FileName,
AltText = m.AltText,
ContentType = m.ContentType,
Url = m.Base64Preview,
Blob = m.Blob
}).ToList(),
Title = messageForm.Title,
User = new MessageUser
{
UserId = CascadingState.User.Id,
UserName = CascadingState.User.UserName,
DisplayName = CascadingState.User.DisplayName,
PictureUrl = CascadingState.User.PictureUrl,
ProfileUrl = CascadingState.User.ProfileUrl,
BackgroundUrl = CascadingState.User.BackgroundUrl
}
});
} }
async ValueTask OnMessageBoost(Message message) async Task OnMessageBoost(Message message)
{ {
var boostedMessageTemp = await MessagesService.BoostUnboostMessage(message); var result = await MessagesService.BoostUnboostMessage(message);
message.BoostsCounter = message.IsBoostedByCurrentUser ? message.BoostsCounter - 1 : message.BoostsCounter + 1; if (result.IsInvalid)
message.IsBoostedByCurrentUser = !message.IsBoostedByCurrentUser;
if (message.IsBoostedByCurrentUser)
{ {
var boostedMessage = new Message Toast.AddResponseError(result);
return;
}
if (!message.IsBoostedByCurrentUser)
{ {
BoostingUser = new() Messages.RemoveAll(m => m.MessageId == message.MessageId && m.BoostingUser?.UserId == CascadingState.User.Id);
return;
}
// The feed shows a boost as a copy of the message carrying the booster.
Messages.Insert(0, new()
{ {
UserId = CascadingState.User.Id, BoostingUser = CascadingState.User.ToBoostingUser(),
DisplayName = CascadingState.User.DisplayName,
PictureUrl = CascadingState.User.PictureUrl,
ProfileUrl = CascadingState.User.ProfileUrl,
UserName = CascadingState.User.UserName
},
BoostsCounter = message.BoostsCounter, BoostsCounter = message.BoostsCounter,
Content = message.Content, Content = message.Content,
CreatedAt = message.CreatedAt, CreatedAt = message.CreatedAt,
@@ -212,48 +144,62 @@
RootMessageId = message.RootMessageId, RootMessageId = message.RootMessageId,
Title = message.Title, Title = message.Title,
User = message.User User = message.User
};
Messages.Insert(0, boostedMessage);
}
else
{
Messages.RemoveAll(m => m.MessageId == message.MessageId && m.BoostingUser != null && m.BoostingUser.UserId == CascadingState.User.Id);
}
}
async ValueTask OnMessageFavourite(Message message)
{
message.IsFavourite = !message.IsFavourite;
var favouriteMessage = await MessagesService.FavouriteUnfavouriteMessage(message);
}
async ValueTask OnMessageDelete(Message message)
{
Messages.Remove(message);
var deleteResult = await MessagesService.DeleteMessage(message);
}
async ValueTask OnMessageMediaDownload(Media media)
{
await Task.Run(() =>
{
}); });
}
async Task OnMessageFavourite(Message message)
{
var result = await MessagesService.FavouriteUnfavouriteMessage(message);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
async Task OnMessageDelete(Message message)
{
var result = await MessagesService.DeleteMessage(message);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Messages.Remove(message);
Toast.AddResponseSuccess(Localizer["Message deleted."]);
}
async Task OnMessageMediaDownload(Media media)
{
await BlazorDownloadFileService.DownloadFileAsync(media.FileName, media.Blob, media.ContentType); await BlazorDownloadFileService.DownloadFileAsync(media.FileName, media.Blob, media.ContentType);
} }
async ValueTask OnUserBlock(MessageUser messageUser) async Task OnUserBlock(MessageUser messageUser)
{ {
var blockResult = await MessagesService.BlockUserFromMessage(messageUser); var result = await MessagesService.BlockUserFromMessage(messageUser);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
} }
async ValueTask OnUserDirectMessage(Message message) Toast.AddResponseSuccess(Localizer["{0} blocked.", messageUser.DisplayName]);
{
var directMessage = await MessagesService.ReplyMessage(message, MessageType.Direct);
} }
async ValueTask OnUserSilence(MessageUser messageUser) async Task OnUserDirectMessage(Message message)
{ {
var silenceResult = await MessagesService.SilenceUserFromMessage(messageUser); var result = await MessagesService.ReplyMessage(message, MessageType.Direct);
if (result.IsInvalid)
Toast.AddResponseError(result);
} }
async Task OnUserSilence(MessageUser messageUser)
{
var result = await MessagesService.SilenceUserFromMessage(messageUser);
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["{0} muted.", messageUser.DisplayName]);
}
} }
-1
View File
@@ -11,6 +11,5 @@
</section> </section>
@code { @code {
[CascadingParameter] CascadingState CascadingState { get; set; }
} }
-2
View File
@@ -11,9 +11,7 @@
</section> </section>
@code { @code {
[CascadingParameter] CascadingState CascadingState { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; } [Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] NavigationManager Navigation { get; set; }
// The app signs in with its own token (TokenAuthStateProvider), so signing out clears that token and the local // The app signs in with its own token (TokenAuthStateProvider), so signing out clears that token and the local
// database. The template's authentication/logout route can't do it: RemoteAuthenticatorView needs the OIDC // database. The template's authentication/logout route can't do it: RemoteAuthenticatorView needs the OIDC
+22 -9
View File
@@ -15,23 +15,32 @@
<ChildContent> <ChildContent>
@* Every theme setting; NavMenu's toggle and slider are shortcuts to the dark and hue ones. *@ @* Every theme setting; NavMenu's toggle and slider are shortcuts to the dark and hue ones. *@
<div class="flex flex-col gap-4 w-full p-3 md:p-4"> <div class="flex flex-col gap-4 w-full p-3 md:p-4">
<DToggle LabelText="@Localizer["Follow the system's light or dark setting"]" Value="Theme.PreferSystemTheming" <DToggle LabelText="@Localizer["Follow the system's light or dark setting"]" Value="CascadingState.PublicCacheData.PageSettings.PreferSystemTheming"
ValueChanged="v => Update(s => s.PreferSystemTheming = v)" ValueExpression="() => Theme.PreferSystemTheming" /> ValueChanged="v => Update(s => s.PreferSystemTheming = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.PreferSystemTheming" />
<DToggle LabelText="@Localizer["Dark mode"]" Value="IsDark" <DToggle LabelText="@Localizer["Dark mode"]" Value="IsDark"
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" /> ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray" <DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" /> ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
<label class="flex flex-col gap-2 max-w-md">
<span class="text-sm">@Localizer["Language"]</span>
@* Language names stay in their own language. Changing it reloads the app with the new culture. *@
<DSelect TValue="string" class="w-full" Value="CascadingState.PublicCacheData.PageSettings.CurrentLanguageCode"
ValueChanged="ChangeLanguage" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.CurrentLanguageCode">
<option value="en">English</option>
<option value="it">Italiano</option>
</DSelect>
</label>
<label class="flex flex-col gap-2 max-w-md"> <label class="flex flex-col gap-2 max-w-md">
<span class="text-sm">@Localizer["Light theme colour"]</span> <span class="text-sm">@Localizer["Light theme colour"]</span>
<DRange TValue="short" class="neo-hue-track w-full" Size="DSize.Xs" Min="0" Max="359" Step="1" <DRange TValue="short" class="neo-hue-track w-full" Size="DSize.Xs" Min="0" Max="359" Step="1"
Value="Theme.LightThemeIndexColour" ValueChanged="v => Update(s => s.SetHue(false, v))" Value="CascadingState.PublicCacheData.PageSettings.LightThemeIndexColour" ValueChanged="v => Update(s => s.SetHue(false, v))"
ValueExpression="() => Theme.LightThemeIndexColour" /> ValueExpression="() => CascadingState.PublicCacheData.PageSettings.LightThemeIndexColour" />
</label> </label>
<label class="flex flex-col gap-2 max-w-md"> <label class="flex flex-col gap-2 max-w-md">
<span class="text-sm">@Localizer["Dark theme colour"]</span> <span class="text-sm">@Localizer["Dark theme colour"]</span>
<DRange TValue="short" class="neo-hue-track w-full" Size="DSize.Xs" Min="0" Max="359" Step="1" <DRange TValue="short" class="neo-hue-track w-full" Size="DSize.Xs" Min="0" Max="359" Step="1"
Value="Theme.DarkThemeIndexColour" ValueChanged="v => Update(s => s.SetHue(true, v))" Value="CascadingState.PublicCacheData.PageSettings.DarkThemeIndexColour" ValueChanged="v => Update(s => s.SetHue(true, v))"
ValueExpression="() => Theme.DarkThemeIndexColour" /> ValueExpression="() => CascadingState.PublicCacheData.PageSettings.DarkThemeIndexColour" />
</label> </label>
</div> </div>
</ChildContent> </ChildContent>
@@ -87,11 +96,15 @@
</section> </section>
@code { @code {
[CascadingParameter] CascadingState CascadingState { get; set; }
PageSettings Theme => CascadingState.PublicCacheData?.PageSettings ?? new();
bool IsDark => CascadingState.Status.IsDarkTheme(); bool IsDark => CascadingState.Status.IsDarkTheme();
bool IsGray => Theme.IsGray(); bool IsGray => CascadingState.PublicCacheData.PageSettings.IsGray();
Task Update(Action<PageSettings> change) => CascadingState.UpdatePageSettings(change).AsTask(); Task Update(Action<PageSettings> change) => CascadingState.UpdatePageSettings(change).AsTask();
async Task ChangeLanguage(string languageCode)
{
await Update(settings => settings.CurrentLanguageCode = languageCode);
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
}
} }
+6 -2
View File
@@ -1,9 +1,10 @@
using decePubClient; using decePubClient;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text.Json; using System.Text.Json;
using Blazored.LocalStorage; using Blazored.LocalStorage;
using decePubClient.Extensions; using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models; using decePubClient.Models;
using decePubClient.Services; using decePubClient.Services;
using Toolbelt.Blazor.Extensions.DependencyInjection; using Toolbelt.Blazor.Extensions.DependencyInjection;
@@ -39,12 +40,15 @@ builder.Services.AddOptions()
config.JsonSerializerOptions.IgnoreReadOnlyProperties = true; config.JsonSerializerOptions.IgnoreReadOnlyProperties = true;
config.JsonSerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip; config.JsonSerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip;
config.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; config.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
config.JsonSerializerOptions.TypeInfoResolver = SUtility.DefaultSerializer.TypeInfoResolver;
}) })
.AddScoped<TokenAuthStateProvider>() .AddScoped<TokenAuthStateProvider>()
.AddScoped<MessagesService>() .AddScoped<MessagesService>()
.AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<TokenAuthStateProvider>()) .AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<TokenAuthStateProvider>())
.AddScoped<ILoggingService, LoggingService>()
.AddScoped<ToastService>()
.AddSingleton(typeof(CoalescingStringLocalizer)) .AddSingleton(typeof(CoalescingStringLocalizer))
//.AddTransient<IHttpService, HttpService>() .AddTransient<IHttpService, HttpService>()
.AddHeadElementHelper(options => options.DisableClientScriptAutoInjection = true) .AddHeadElementHelper(options => options.DisableClientScriptAutoInjection = true)
.AddLocalization() .AddLocalization()
.AddTransient<IStorage, Storage>() .AddTransient<IStorage, Storage>()
+12 -22
View File
@@ -4,20 +4,12 @@ using Microsoft.JSInterop;
namespace decePubClient.Services; namespace decePubClient.Services;
public class AppStatusService public class AppStatusService(IJSRuntime jsRuntime)
{ {
readonly IJSRuntime JsRuntime; readonly IJSInProcessRuntime JsSyncRuntime = (IJSInProcessRuntime)jsRuntime;
readonly IJSInProcessRuntime JsSyncRuntime;
public AppStatusService(IJSRuntime JSRuntime) public async ValueTask<bool> IsOnline(CancellationToken cancellationToken = default) =>
{ await jsRuntime.InvokeAsync<bool>("isOnline", cancellationToken);
JsRuntime = JSRuntime;
JsSyncRuntime = (IJSInProcessRuntime)JSRuntime;
}
public async ValueTask<bool> IsOnline() => await JsSyncRuntime.InvokeAsync<bool>("isOnline");
public bool InputCaptureSupported() => JsSyncRuntime.Invoke<bool>("inputCaptureSupported");
public bool IsServiceWorkerAvailable() => JsSyncRuntime.Invoke<bool>("isServiceWorkerAvailable"); public bool IsServiceWorkerAvailable() => JsSyncRuntime.Invoke<bool>("isServiceWorkerAvailable");
@@ -31,15 +23,11 @@ public class AppStatusService
public void ClearAppBadge() => JsSyncRuntime.InvokeVoid("clearAppBadge"); public void ClearAppBadge() => JsSyncRuntime.InvokeVoid("clearAppBadge");
public async ValueTask ClearCache() public async ValueTask ClearCache(CancellationToken cancellationToken = default) =>
{ await jsRuntime.InvokeVoidAsync("clearCache", cancellationToken);
await JsRuntime.InvokeVoidAsync("clearCache");
}
public async ValueTask ClearLocalStorage() public async ValueTask ClearLocalStorage(CancellationToken cancellationToken = default) =>
{ await jsRuntime.InvokeVoidAsync("clearLocalStorage", cancellationToken);
await JsRuntime.InvokeVoidAsync("clearLocalStorage");
}
public void ReloadPage() public void ReloadPage()
{ {
@@ -55,7 +43,9 @@ public class AppStatusService
public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff"); public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff");
public async ValueTask ShareTextUrl(string title, string text, Uri uri) => await JsRuntime.InvokeVoidAsync("shareTextUrl", title, text, uri.ToString()); public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) =>
await jsRuntime.InvokeVoidAsync("shareTextUrl", cancellationToken, title, text, uri.ToString());
public async ValueTask ShareText(string title, string text) => await JsRuntime.InvokeVoidAsync("shareText", title, text); public async ValueTask ShareText(string title, string text, CancellationToken cancellationToken = default) =>
await jsRuntime.InvokeVoidAsync("shareText", cancellationToken, title, text);
} }
+42 -33
View File
@@ -6,50 +6,59 @@ using PrivaPub.ClientModels.Resources;
namespace collAnon.Client.Services namespace collAnon.Client.Services
{ {
public sealed class CoalescingStringLocalizer /// <summary>
/// The app's localizer: looks a key up in Resources/AllStrings, then in PrivaPub.ClientModels' FieldsNameResource and
/// ErrorsResource, and falls back to the key itself (formatted with the arguments), so a missing key shows its
/// English text instead of failing.
/// </summary>
public sealed class CoalescingStringLocalizer(IStringLocalizer<AllStrings> baseLocalizer, IStringLocalizer<FieldsNameResource> fieldsLocalizer,
IStringLocalizer<ErrorsResource> errorsLocalizer)
{ {
public readonly IStringLocalizer<AllStrings> _pLocalizer; public IStringLocalizer Base => baseLocalizer;
readonly IStringLocalizer<FieldsNameResource> _fLocalizer; public IStringLocalizer Fields => fieldsLocalizer;
readonly IStringLocalizer<ErrorsResource> _eLocalizer; public IStringLocalizer Errors => errorsLocalizer;
public CoalescingStringLocalizer( public LocalizedString this[string name]
IStringLocalizer<AllStrings> pLocalizer,
IStringLocalizer<FieldsNameResource> fLocalizer,
IStringLocalizer<ErrorsResource> eLocalizer) =>
(_pLocalizer, _fLocalizer, _eLocalizer) =
(pLocalizer, fLocalizer, eLocalizer);
internal LocalizedString this[string name]
{ {
get get
{ {
if (_pLocalizer[name].ResourceNotFound) try
if (_fLocalizer[name].ResourceNotFound) {
if (_eLocalizer[name].ResourceNotFound) if (!baseLocalizer[name].ResourceNotFound)
return new(name, name, false); return baseLocalizer[name];
else if (!fieldsLocalizer[name].ResourceNotFound)
return _eLocalizer[name]; return fieldsLocalizer[name];
else if (!errorsLocalizer[name].ResourceNotFound)
return _fLocalizer[name]; return errorsLocalizer[name];
else return new(name, name, true);
return _pLocalizer[name]; }
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
return new(name, name, true);
}
} }
} }
internal LocalizedString this[string name, params object[] arguments] public LocalizedString this[string name, params object[] arguments]
{ {
get get
{ {
if (_pLocalizer[name].ResourceNotFound) try
if (_fLocalizer[name].ResourceNotFound) {
if (_eLocalizer[name].ResourceNotFound) if (!baseLocalizer[name].ResourceNotFound)
return new(name, name, false); return baseLocalizer[name, arguments];
else if (!fieldsLocalizer[name].ResourceNotFound)
return _eLocalizer[name, arguments]; return fieldsLocalizer[name, arguments];
else if (!errorsLocalizer[name].ResourceNotFound)
return _fLocalizer[name, arguments]; return errorsLocalizer[name, arguments];
else return new(name, string.Format(name, arguments), true);
return _pLocalizer[name, arguments]; }
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
return new(name, name, true);
}
} }
} }
} }
+166 -145
View File
@@ -1,196 +1,217 @@
using Blazored.LocalStorage;
using System.Globalization;
using System.Net.Http.Json;
using System.Net; using System.Net;
using System.Net.Http.Json;
using collAnon.Client.Services;
using decePubClient.Helpers;
using decePubClient.Models; using decePubClient.Models;
namespace decePubClient.Services namespace decePubClient.Services
{ {
/// <summary>
/// Requests to the PrivaPub API through the "default" HttpClient (AppConfiguration.ApiBaseAddress), at three
/// authentication levels: Get/Post/Delete need a valid token and sign out on 401, *Anon add the token when there is
/// one, *TotallyAnon never send it. Nothing throws: a failure comes back as a response carrying an invalid WebResult
/// (SUtility.ClassifiedFailure, read with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410.
/// With retryOnError, 408/429/502/503/504 and transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then
/// every 60, plus jitter, for at most 10 minutes.
/// </summary>
public interface IHttpService public interface IHttpService
{ {
Task<HttpResponseMessage> Get(string uri, object payload = default, string[] queryParams = default); Task<HttpResponseMessage> Get(string uri, string[] queryParams = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> GetAnon(string uri, object payload = default, string[] queryParams = default); Task<HttpResponseMessage> GetAnon(string uri, string[] queryParams = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> Post(string uri, object payload = default); Task<HttpResponseMessage> GetTotallyAnon(string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
Task<HttpResponseMessage> PostAnon(string uri, object payload = default); Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default); Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
} }
public class HttpService : IHttpService public class HttpService(IHttpClientFactory httpClientFactory, TokenAuthStateProvider authStateProvider, AppStatusService statusService,
ILoggingService logger, CoalescingStringLocalizer localizer) : IHttpService
{ {
readonly IHttpClientFactory HttpClientFactory; static readonly int[] RetryRampUpDelaysMs = [1_000, 2_000, 4_000, 8_000, 16_000, 29_000];
readonly TokenAuthStateProvider AuthStateProvider; const int RetryIndefiniteDelayMs = 60_000;
readonly ILogger<HttpService> Logger; static readonly TimeSpan RetryDeadline = TimeSpan.FromMinutes(10);
readonly ILocalStorageService Storage; static readonly int[] RetryableStatusCodes = [408, 429, 502, 503, 504];
readonly IStorage DbStorage;
public HttpService( readonly HttpClient httpClient = httpClientFactory.CreateClient("default");
IHttpClientFactory httpClientFactory,
TokenAuthStateProvider authStateProvider,
ILogger<HttpService> logger,
ILocalStorageService storage,
IStorage dbStorage)
{
HttpClientFactory = httpClientFactory;
AuthStateProvider = authStateProvider;
Logger = logger;
Storage = storage;
DbStorage = dbStorage;
}
public async Task<HttpResponseMessage> Get(string uri, object payload = default, string[] queryParams = default) public Task<HttpResponseMessage> Get(string uri, string[] queryParams = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(Get), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), SendAuthRequest, retryOnError, cancellationToken);
public Task<HttpResponseMessage> GetAnon(string uri, string[] queryParams = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(GetAnon), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), SendAuthIfAvailableRequest, retryOnError,
cancellationToken);
public Task<HttpResponseMessage> GetTotallyAnon(string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(GetTotallyAnon), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), httpClient.SendAsync, retryOnError,
cancellationToken);
public Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(Post), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
cancellationToken);
public Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(PostTotallyAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), httpClient.SendAsync, retryOnError,
cancellationToken);
public Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken);
public Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(DeleteTotallyAnon), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), httpClient.SendAsync, retryOnError,
cancellationToken);
async Task<HttpResponseMessage> Send(string caller, Func<HttpRequestMessage> requestFactory,
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send, bool retryOnError, CancellationToken cancellationToken)
{ {
var deadline = DateTime.UtcNow + RetryDeadline;
var attempt = 0;
while (true)
{
// A request message can be sent once, so every attempt builds its own.
var request = requestFactory();
var where = $"{nameof(HttpService)}.{caller}:{request.RequestUri?.OriginalString}";
var failure = default(string);
var transportException = default(Exception);
try try
{ {
uri = $"{CultureInfo.DefaultThreadCurrentCulture.TwoLetterISOLanguageName}/{uri}"; var response = await send(request, cancellationToken);
var request = default(HttpRequestMessage); var status = (int)response.StatusCode;
if (queryParams != null) if (!retryOnError || !RetryableStatusCodes.Contains(status))
request = new(HttpMethod.Get, string.Join('?', uri, string.Join('&', queryParams))); return status > 499 ? ServerError(response) : response;
else
request = new(HttpMethod.Get, uri); failure = $"HTTP {status}";
if (payload != null) response.Dispose();
request.Content = JsonContent.Create(payload); }
return await SendRequest(request); catch (Exception) when (cancellationToken.IsCancellationRequested)
{
return SUtility.DefaultTaskCancelled();
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
if (!retryOnError)
{
await logger.ProcessError(ex, where, cancellationToken);
return await DescribeUnreachable(ex, 1, cancellationToken);
}
failure = ex.Message;
transportException = ex;
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(Get)}:{uri}"); await logger.ProcessError(ex, where, cancellationToken);
await DbStorage.AddLog(ex, $"{nameof(Get)}:{uri}"); return SUtility.ClassifiedFailure(FailureCodes.ClientError,
return new(HttpStatusCode.ServiceUnavailable); localizer["The request could not be prepared. Reload the page and try again."], HttpStatusCode.ServiceUnavailable);
}
} }
public async Task<HttpResponseMessage> Post(string uri, object payload = default) var delay = attempt < RetryRampUpDelaysMs.Length ? RetryRampUpDelaysMs[attempt] : RetryIndefiniteDelayMs;
delay += Random.Shared.Next(0, delay / 4);
if (deadline - DateTime.UtcNow <= TimeSpan.FromMilliseconds(delay))
{ {
await logger.ProcessError($"Gave up after {attempt + 1} attempts. {failure}", where, cancellationToken);
if (transportException is not null)
return await DescribeUnreachable(transportException, attempt + 1, cancellationToken);
return SUtility.ClassifiedFailure(FailureCodes.RetriesExhausted,
localizer["The server is still not answering after {0} attempts. Try again later.", attempt + 1], HttpStatusCode.ServiceUnavailable);
}
await logger.ProcessWarning($"Attempt {attempt + 1} failed, retrying in {delay}ms. {failure}", where, cancellationToken);
try try
{ {
uri = $"{CultureInfo.DefaultThreadCurrentCulture.TwoLetterISOLanguageName}/{uri}"; await Task.Delay(delay, cancellationToken);
var request = new HttpRequestMessage(HttpMethod.Post, uri)
{
Content = JsonContent.Create(payload)
};
return await SendRequest(request);
} }
catch (Exception ex) catch (OperationCanceledException)
{ {
Logger.LogError(ex, $"{nameof(Post)}:{uri}"); return SUtility.DefaultTaskCancelled();
await DbStorage.AddLog(ex, $"{nameof(Post)}:{uri}"); }
return new(HttpStatusCode.ServiceUnavailable);
attempt++;
} }
} }
public async Task<HttpResponseMessage> GetAnon(string uri, object payload = default, string[] queryParams = default) HttpResponseMessage ServerError(HttpResponseMessage response)
{ {
try var status = response.StatusCode;
{ response.Dispose();
uri = $"{CultureInfo.DefaultThreadCurrentCulture.TwoLetterISOLanguageName}/{uri}"; return SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server reported an internal error (HTTP {0}).", (int)status], status);
var request = default(HttpRequestMessage);
if (queryParams != null)
request = new(HttpMethod.Get, string.Join('?', uri, string.Join('&', queryParams)));
else
request = new(HttpMethod.Get, uri);
if (payload != null)
request.Content = JsonContent.Create(payload);
return await SendAnonRequest(request);
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(GetAnon)}:{uri}");
await DbStorage.AddLog(ex, $"{nameof(GetAnon)}:{uri}");
return new(HttpStatusCode.ServiceUnavailable);
}
} }
public async Task<HttpResponseMessage> PostAnon(string uri, object payload = default) /// <summary>Why a request got no answer: the device is offline, it timed out, retries ran out, or the server is unreachable.</summary>
async Task<HttpResponseMessage> DescribeUnreachable(Exception exception, int attempts, CancellationToken cancellationToken)
{ {
try if (!await statusService.IsOnline(cancellationToken))
{ return SUtility.ClassifiedFailure(FailureCodes.DeviceOffline,
uri = $"{CultureInfo.DefaultThreadCurrentCulture.TwoLetterISOLanguageName}/{uri}"; localizer["This device is offline. Check the connection and try again."], HttpStatusCode.ServiceUnavailable);
var request = new HttpRequestMessage(HttpMethod.Post, uri)
{ if (exception is TaskCanceledException { InnerException: TimeoutException })
Content = JsonContent.Create(payload) return SUtility.ClassifiedFailure(FailureCodes.Timeout,
}; localizer["The server took too long to answer. Try again later."], HttpStatusCode.ServiceUnavailable);
return await SendAnonRequest(request);
} if (attempts > 1)
catch (Exception ex) return SUtility.ClassifiedFailure(FailureCodes.RetriesExhausted,
{ localizer["The server is still not answering after {0} attempts. Try again later.", attempts], HttpStatusCode.ServiceUnavailable);
Logger.LogError(ex, $"{nameof(PostAnon)}:{uri}");
await DbStorage.AddLog(ex, $"{nameof(PostAnon)}:{uri}"); return SUtility.ClassifiedFailure(FailureCodes.ApiUnreachable,
return new(HttpStatusCode.ServiceUnavailable); localizer["The server can't be reached. Try again later."], HttpStatusCode.ServiceUnavailable);
}
} }
public async Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default) async Task<HttpResponseMessage> SendAuthRequest(HttpRequestMessage request, CancellationToken cancellationToken)
{ {
try var token = await authStateProvider.GetToken(cancellationToken);
if (token is null)
{ {
uri = $"{CultureInfo.DefaultThreadCurrentCulture.TwoLetterISOLanguageName}/{uri}"; logger.Process($"NO_TOKEN:{request.RequestUri?.OriginalString}", nameof(SendAuthRequest), LogLevel.Warning);
var request = default(HttpRequestMessage); return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
if (queryParams != null)
request = new(HttpMethod.Delete, string.Join('?', uri, string.Join('&', queryParams)));
else
request = new(HttpMethod.Delete, uri);
if (payload != null)
request.Content = JsonContent.Create(payload);
return await SendRequest(request);
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(Delete)}:{uri}");
await DbStorage.AddLog(ex, $"{nameof(Delete)}:{uri}");
return new(HttpStatusCode.ServiceUnavailable);
}
}
// helper methods
async Task<HttpResponseMessage> SendRequest(HttpRequestMessage request)
{
try
{
var authData = await Storage.GetItemAsync<AuthData>(nameof(AuthData));
var isApiUrl = !request.RequestUri?.IsAbsoluteUri;
if (isApiUrl.HasValue && isApiUrl.Value)
request.Headers.Authorization = new("Bearer", authData.Token);
var response = await HttpClientFactory.CreateClient().SendAsync(request);
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Logger.LogWarning($"{nameof(SendRequest)}:401/403:{request.RequestUri?.OriginalString}:{await response.Content.ReadAsStringAsync()}");
await DbStorage.AddLog($"{nameof(SendRequest)}:401/403:{request.RequestUri?.OriginalString}:{await response.Content.ReadAsStringAsync()}", $"{nameof(SendRequest)}:{request.RequestUri?.OriginalString}");
await AuthStateProvider.LogoutAsync();
} }
request.Headers.Authorization = new("Bearer", token);
var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode is not HttpStatusCode.Unauthorized)
return response; return response;
}
catch (Exception ex) response.Dispose();
{ await authStateProvider.LogoutAsync(cancellationToken: cancellationToken);
Logger.LogError(ex, $"{nameof(SendRequest)}:{request.RequestUri?.OriginalString}"); return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
await DbStorage.AddLog(ex, $"{nameof(SendRequest)}:{request.RequestUri?.OriginalString}");
return new(HttpStatusCode.ServiceUnavailable);
}
} }
async Task<HttpResponseMessage> SendAnonRequest(HttpRequestMessage request) async Task<HttpResponseMessage> SendAuthIfAvailableRequest(HttpRequestMessage request, CancellationToken cancellationToken)
{ {
try var token = await authStateProvider.GetToken(cancellationToken);
{ if (token is not null)
var authData = await Storage.GetItemAsync<AuthData>(nameof(AuthData)); request.Headers.Authorization = new("Bearer", token);
if (!string.IsNullOrEmpty(authData?.Token)) return await httpClient.SendAsync(request, cancellationToken);
request.Headers.Authorization = new("Bearer", authData.Token);
var response = await HttpClientFactory.CreateClient().SendAsync(request);
return response;
} }
catch (Exception ex)
static HttpRequestMessage BuildRequest(HttpMethod method, string uri, object payload = default, string[] queryParams = default,
bool forceContent = false)
{ {
Logger.LogError(ex, $"{nameof(SendAnonRequest)}:{request.RequestUri?.OriginalString}"); var target = queryParams is { Length: > 0 } ? $"{uri}?{string.Join('&', queryParams)}" : uri;
await DbStorage.AddLog(ex, $"{nameof(SendAnonRequest)}:{request.RequestUri?.OriginalString}"); var request = new HttpRequestMessage(method, target);
return new(HttpStatusCode.ServiceUnavailable); if (payload is not null || forceContent)
} request.Content = JsonContent.Create(payload, payload?.GetType() ?? typeof(object), options: SUtility.DefaultSerializer);
return request;
} }
} }
} }
+61
View File
@@ -0,0 +1,61 @@
namespace decePubClient.Services;
public interface ILoggingService
{
ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default);
ValueTask ProcessError(string message, string where, CancellationToken cancellationToken = default);
ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default);
void Process(string message, string where = default, LogLevel level = LogLevel.Information);
}
/// <summary>
/// Writes to the browser console through ILogger and keeps errors and warnings in the IndexedDB client log, so they
/// survive a reload. Never throws: logging must not break the code that is reporting a failure.
/// </summary>
public class LoggingService(IStorage storage, ILogger<LoggingService> logger) : ILoggingService
{
public void Process(string message, string where = default, LogLevel level = LogLevel.Information) =>
logger.Log(level, "[{Where}] {Message}", where ?? "N/A", message);
public async ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default)
{
try
{
logger.LogError(exception, "[{Where}]", where);
await storage.AddLog(exception, where, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessError)}");
}
}
public async ValueTask ProcessError(string message, string where, CancellationToken cancellationToken = default)
{
try
{
logger.LogError("[{Where}] {Message}", where, message);
await storage.AddLog(message, where, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessError)}");
}
}
public async ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
{
try
{
logger.LogWarning("[{Where}] {Message}", where, message);
await storage.AddLog(message, where, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessWarning)}");
}
}
}
+108 -310
View File
@@ -1,316 +1,115 @@
using decePubClient.Helpers; using decePubClient.Helpers;
using decePubClient.Models; using decePubClient.Models;
using decePubClient.Models.Types;
using Markdig; using PrivaPub.ClientModels;
namespace decePubClient.Services namespace decePubClient.Services
{ {
/// <summary>
/// The IndexedDB database "data" (schema in GenericExtensions.AddIndexedDb): messages and the client log. Lists
/// come back empty on failure, mutations as an invalid WebResult; nothing throws. Failures are logged with ILogger
/// only, because ILoggingService writes its log through this class.
/// </summary>
public interface IStorage public interface IStorage
{ {
ValueTask<List<Message>> GetMessages(); Task<List<Message>> GetMessages(CancellationToken cancellationToken = default);
ValueTask<Message> GetMessage(string messageId);
ValueTask<string> AddMessages(List<Message> messages);
ValueTask<string> UpdateMessages(List<Message> messages);
ValueTask<string> RemoveMessage(string messageId);
ValueTask<ICollection<ClientLogs>> GetClientLogs(); Task<Message> GetMessage(string messageId, CancellationToken cancellationToken = default);
ValueTask AddLog(Exception exception, string where);
ValueTask AddLog(string message, string where);
ValueTask<bool> RemoveAll(bool includeClientLogs = false); Task<WebResult> AddMessages(List<Message> messages, CancellationToken cancellationToken = default);
Task<WebResult> UpdateMessages(List<Message> messages, CancellationToken cancellationToken = default);
Task<WebResult> RemoveMessage(string messageId, CancellationToken cancellationToken = default);
Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default);
Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default);
Task AddLog(string message, string where, CancellationToken cancellationToken = default);
Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default);
} }
public class Storage : IStorage public class Storage(IndexedDb db, ILogger<Storage> logger) : IStorage
{ {
readonly IndexedDb _db; /// <summary>The client log keeps at most this many entries; past it the oldest half is dropped.</summary>
readonly ILogger<Storage> _logger; const int MaxLogs = 100;
public Storage(IndexedDb indexedDb, ILogger<Storage> logger) #region Messages
public async Task<Message> GetMessage(string messageId, CancellationToken cancellationToken = default)
{ {
_db = indexedDb; try
_logger = logger;
}
#region Messagges
public async ValueTask<Message> GetMessage(string messageId)
{ {
await _db.OpenIndexedDb(); cancellationToken.ThrowIfCancellationRequested();
var message = await _db.GetByKey<string, Message>(nameof(Message), messageId); await db.OpenIndexedDb();
var message = await db.GetByKey<string, Message>(nameof(Message), messageId);
if (message is not null)
return message;
if (message is null) var messages = await GetMessages(cancellationToken);
{
var messages = await GetMessages();
return messages.FirstOrDefault(m => m.MessageId == messageId); return messages.FirstOrDefault(m => m.MessageId == messageId);
} }
catch (Exception ex)
return message; {
logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetMessage)}");
return default;
}
} }
public async ValueTask<List<Message>> GetMessages() /// <summary>The stored messages, newest first; while none are stored, the mock feed (Faker.SeedMessages).</summary>
public async Task<List<Message>> GetMessages(CancellationToken cancellationToken = default)
{ {
await _db.OpenIndexedDb(); try
var messages = await _db.GetAll<Message>(nameof(Message)) ?? new(); {
cancellationToken.ThrowIfCancellationRequested();
await db.OpenIndexedDb();
var messages = await db.GetAll<Message>(nameof(Message)) ?? [];
if (messages.Count == 0) if (messages.Count == 0)
{ messages = Faker.SeedMessages();
messages.Add(new()
{ return [.. messages.OrderByDescending(m => m.CreatedAt)];
RootMessageId = default,
MessageId = "992167EE-8947-4823-B1E2-B4E1019B6974",
User = Faker.GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test di messaggio normale senza titolo",
Medias = new(),
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "C5307644-4EA8-4B5F-A240-7CFE4DE34741",
User = Faker.GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = "sopra la panca la capra canta",
Content = Markdown.ToHtml("test di messaggio con titolo"),
Medias = new(),
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "E473D562-D210-4453-93DA-157D4B90B0D8",
User = Faker.GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd test di messaggio con overflow del testo",
Medias = new(),
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "adf89dc6-0484-430e-a7c9-fcb1179b0c28",
User = Faker.GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test video",
Medias = new()
{
new()
{
FileName = "8252d175e714db88beb8ee0349dbac90405d3d5148e96e0cfd7de3b0876dbb1b.mp4",
Url = "https://ihatebeinga.live/media/8252d175e714db88beb8ee0349dbac90405d3d5148e96e0cfd7de3b0876dbb1b.mp4",
AltText = "https://ihatebeinga.live/media/8252d175e714db88beb8ee0349dbac90405d3d5148e96e0cfd7de3b0876dbb1b.mp4",
ContentType = "video/mp4"
} }
}, catch (Exception ex)
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{ {
RootMessageId = default, logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetMessages)}");
MessageId = "96025b43-5235-44e9-a3fa-98df31edfbfb", return [];
User = Faker.GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test immagine",
Medias = new()
{
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
} }
},
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "277ca76b-aaad-4aa6-95fb-25f59e2fddf9",
User = Faker.GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test audio",
Medias = new()
{
new()
{
FileName = "09bfb06d0096975f.mp3",
Url = "https://cdn.masto.host/mastodonuno/media_attachments/files/107/798/288/501/917/857/original/09bfb06d0096975f.mp3",
AltText = "09bfb06d0096975f.mp3",
ContentType = "audio/mp3"
}
},
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0, 100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
BoostingUser = Faker.GetRandomBoostingUser(),
RootMessageId = default,
MessageId = "de64e520-a7d3-4f31-b5a9-ba236f3b146c",
User = Faker.GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test immagini",
Medias = new()
{
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
},
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
},
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
}
},
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0,100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
messages.Add(new()
{
RootMessageId = default,
MessageId = "de64e520-a7d3-4f31-b5a9-ba236f3b146c",
User = Faker.GetRandomUser(),
MessageType = (MessageType)Random.Shared.Next(0, 4),
Title = default,
Content = "test immagini",
Medias = new()
{
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
},
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
},
new()
{
FileName = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
Url = "https://ihatebeinga.live/media/51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
AltText = "51a7cf620f1dd096ac5867d5b78f8c71d57a23972b31c64ec124a8c41e77a618.jpg",
ContentType = "image/jpg"
}
},
CreatedAt = DateTime.UtcNow.AddMinutes(Random.Shared.Next(-5000, 0)),
IsFavourite = Random.Shared.Next() % 2 == 0,
BoostsCounter = Random.Shared.Next(0,100),
IsBoostedByCurrentUser = Random.Shared.Next() % 2 == 0,
});
} }
messages = messages.OrderByDescending(m => m.CreatedAt).ToList(); public Task<WebResult> AddMessages(List<Message> messages, CancellationToken cancellationToken = default) =>
Run(nameof(AddMessages), async () => await db.AddItems(nameof(Message), messages), cancellationToken);
return messages; public Task<WebResult> UpdateMessages(List<Message> messages, CancellationToken cancellationToken = default) =>
} Run(nameof(UpdateMessages), async () => await db.UpdateItems(nameof(Message), messages), cancellationToken);
public async ValueTask<string> AddMessages(List<Message> messages) public Task<WebResult> RemoveMessage(string messageId, CancellationToken cancellationToken = default) =>
{ Run(nameof(RemoveMessage), async () => await db.DeleteByKey(nameof(Message), messageId), cancellationToken);
await _db.OpenIndexedDb();
var result = await _db.AddItems(nameof(Message), messages);
_logger.LogInformation(result);
return result;
}
public async ValueTask<string> UpdateMessages(List<Message> messages)
{
await _db.OpenIndexedDb();
var result = await _db.UpdateItems(nameof(Message), messages);
_logger.LogInformation(result);
return result;
}
public async ValueTask<string> RemoveMessage(string messageId)
{
await _db.OpenIndexedDb();
var result = await _db.DeleteByKey(nameof(Message), messageId);
_logger.LogInformation(result);
return result;
}
#endregion #endregion
#region Logs #region Logs
public async ValueTask<ICollection<ClientLogs>> GetClientLogs() public async Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default)
{ {
try try
{ {
_logger.LogInformation($"opening on {nameof(GetClientLogs)}"); cancellationToken.ThrowIfCancellationRequested();
await _db.OpenIndexedDb(); await db.OpenIndexedDb();
return (await _db.GetAll<ClientLogs>(nameof(ClientLogs))).OrderByDescending(cl => cl.TimeStamp).ToArray(); var logs = await db.GetAll<ClientLogs>(nameof(ClientLogs)) ?? [];
return [.. logs.OrderByDescending(log => log.TimeStamp)];
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, ex.Message); logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetClientLogs)}");
return new List<ClientLogs>(); return [];
} }
} }
public async ValueTask AddLog(Exception exception, string where) public Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default) =>
AddLog(new ClientLogs
{ {
try
{
await _db.OpenIndexedDb();
var maxKey = await _db.GetMaxKey<long>(nameof(ClientLogs));
var minKey = await _db.GetMinKey<long>(nameof(ClientLogs));
if (minKey - maxKey <= -100)
for (var i = minKey; i < minKey + 50; i++)
_ = await _db.DeleteByKey(nameof(ClientLogs), i);
maxKey += 1;
var result = await _db.AddItems<ClientLogs>(nameof(ClientLogs), new()
{
new()
{
Id = maxKey,
Where = where, Where = where,
Exception = new() Exception = new()
{ {
@@ -323,67 +122,66 @@ namespace decePubClient.Services
StackTrace = exception.StackTrace, StackTrace = exception.StackTrace,
TargetSiteName = exception.TargetSite?.Name TargetSiteName = exception.TargetSite?.Name
} }
} }, cancellationToken);
});
_logger.LogDebug($"{nameof(AddLog)}() add logs result = {result}");
}
catch (Exception ex)
{
_logger.LogError(ex, ex.Message);
}
}
public async ValueTask AddLog(string message, string where) public Task AddLog(string message, string where, CancellationToken cancellationToken = default) =>
AddLog(new ClientLogs { Where = where, WarningMessage = message }, cancellationToken);
/// <summary>Numbers the entry after the newest one and trims the log; never throws.</summary>
async Task AddLog(ClientLogs log, CancellationToken cancellationToken)
{ {
try try
{ {
await _db.OpenIndexedDb(); cancellationToken.ThrowIfCancellationRequested();
await db.OpenIndexedDb();
var maxKey = await _db.GetMaxKey<long>(nameof(ClientLogs)); var maxKey = await db.GetMaxKey<long>(nameof(ClientLogs));
var minKey = await _db.GetMinKey<long>(nameof(ClientLogs)); var minKey = await db.GetMinKey<long>(nameof(ClientLogs));
if (maxKey - minKey >= MaxLogs)
for (var key = minKey; key < minKey + MaxLogs / 2; key++)
_ = await db.DeleteByKey(nameof(ClientLogs), key);
if (minKey - maxKey <= -100) log.Id = maxKey + 1;
for (var i = minKey; i < minKey + 50; i++) var result = await db.AddItems<ClientLogs>(nameof(ClientLogs), [log]);
_ = await _db.DeleteByKey(nameof(ClientLogs), i); logger.LogDebug($"{nameof(AddLog)}() add logs result = {result}");
maxKey += 1;
var result = await _db.AddItems<ClientLogs>(nameof(ClientLogs), new()
{
new()
{
Id = maxKey,
Where = where,
Exception = null,
WarningMessage = message
}
});
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, ex.Message); logger.LogError(ex, $"{nameof(Storage)}.{nameof(AddLog)}");
} }
} }
#endregion #endregion
public async ValueTask<bool> RemoveAll(bool includeClientLogs = false) public Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default) =>
Run(nameof(RemoveAll), async () =>
{ {
var result = await db.DeleteAll(nameof(Message));
if (includeClientLogs)
result = await db.DeleteAll(nameof(ClientLogs));
return result;
}, cancellationToken);
/// <summary>Runs a write and turns its outcome into a WebResult.</summary>
async Task<WebResult> Run(string caller, Func<Task<string>> write, CancellationToken cancellationToken)
{
var result = new WebResult();
try try
{ {
_logger.LogInformation($"opening on {nameof(RemoveAll)}"); cancellationToken.ThrowIfCancellationRequested();
await _db.OpenIndexedDb(); await db.OpenIndexedDb();
_ = await _db.DeleteAll(nameof(Message)); logger.LogInformation("{Caller}: {Result}", caller, await write());
if (includeClientLogs) return result;
_ = await _db.DeleteAll(nameof(ClientLogs)); }
catch (OperationCanceledException)
return true; {
return SUtility.DefaultTaskCancelledResult;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, ex.Message); logger.LogError(ex, $"{nameof(Storage)}.{caller}");
return default; return result.Invalidate(ex.Message, exception: ex);
} }
} }
} }
} }
+2 -6
View File
@@ -1,14 +1,10 @@
using DnetIndexedDb; using DnetIndexedDb;
using Microsoft.JSInterop; using Microsoft.JSInterop;
namespace decePubClient.Services namespace decePubClient.Services
{ {
public class IndexedDb : IndexedDbInterop public class IndexedDb(IJSRuntime jsRuntime, IndexedDbOptions<IndexedDb> options) : IndexedDbInterop(jsRuntime, options)
{ {
public IndexedDb(IJSRuntime jsRuntime, IndexedDbOptions<IndexedDb> options)
: base(jsRuntime, options)
{
}
} }
} }
+75 -61
View File
@@ -1,74 +1,88 @@
using decePubClient.Models; using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Types; using decePubClient.Models.Types;
using PrivaPub.ClientModels;
namespace decePubClient.Services namespace decePubClient.Services
{ {
public class MessagesService /// <summary>
/// Message actions. Still a mock: the app isn't wired to PrivaPub yet, so each method builds its result in memory.
/// The signatures are the real ones (a Task&lt;WebResult&gt; whose Data is the updated entity), so wiring means
/// calling httpService against PrivaPub's /clientapi/post/* routes inside the same try/catch, reading failures with
/// ReadWebResult and the body with DefaultReadFromJsonAsync, as b2b.next's StoreService does.
/// </summary>
public class MessagesService(ILoggingService logger, CoalescingStringLocalizer localizer)
{ {
readonly IStorage storage; /// <summary>Posts a message or, with RootMessageId set, a reply; Data is the new Message.</summary>
readonly HttpClient http; public Task<WebResult> SubmitMessage(MessageForm messageForm, User author, CancellationToken cancellationToken = default) =>
Run(nameof(SubmitMessage), () => (object)new Message
{
RootMessageId = messageForm.RootMessageId,
MessageId = Guid.NewGuid().ToString(),
Content = messageForm.Content?.ParseContent(messageForm.ContentType),
CreatedAt = DateTime.UtcNow,
MessageType = messageForm.MessageType,
Medias = [.. messageForm.Media.Select(m => new Media
{
FileName = m.FileName,
AltText = m.AltText,
ContentType = m.ContentType,
Url = m.Base64Preview,
Blob = m.Blob
})],
Title = messageForm.Title,
User = author.ToMessageUser()
}, cancellationToken);
public MessagesService(IHttpClientFactory clientFactory, IStorage storage) /// <summary>Opens a reply of the given scope to a message; Data is the message.</summary>
public Task<WebResult> ReplyMessage(Message messageToReply, MessageType messageType = MessageType.Public, CancellationToken cancellationToken = default) =>
Run(nameof(ReplyMessage), () => messageToReply, cancellationToken);
/// <summary>Boosts the message, or takes the boost back; Data is the message with its counter updated.</summary>
public Task<WebResult> BoostUnboostMessage(Message message, CancellationToken cancellationToken = default) =>
Run(nameof(BoostUnboostMessage), () =>
{ {
this.storage = storage; message.BoostsCounter += message.IsBoostedByCurrentUser ? -1 : 1;
http = clientFactory.CreateClient("default"); message.IsBoostedByCurrentUser = !message.IsBoostedByCurrentUser;
return message;
}, cancellationToken);
/// <summary>Adds the message to the favourites, or removes it; Data is the updated message.</summary>
public Task<WebResult> FavouriteUnfavouriteMessage(Message message, CancellationToken cancellationToken = default) =>
Run(nameof(FavouriteUnfavouriteMessage), () =>
{
message.IsFavourite = !message.IsFavourite;
return message;
}, cancellationToken);
public Task<WebResult> DeleteMessage(Message message, CancellationToken cancellationToken = default) =>
Run(nameof(DeleteMessage), () => message, cancellationToken);
public Task<WebResult> BlockUserFromMessage(MessageUser messageUser, CancellationToken cancellationToken = default) =>
Run(nameof(BlockUserFromMessage), () => messageUser, cancellationToken);
public Task<WebResult> SilenceUserFromMessage(MessageUser messageUser, CancellationToken cancellationToken = default) =>
Run(nameof(SilenceUserFromMessage), () => messageUser, cancellationToken);
/// <summary>The mock's stand-in for an API call: the same success/failure handling, with the outcome built locally.</summary>
async Task<WebResult> Run(string caller, Func<object> action, CancellationToken cancellationToken)
{
var result = new WebResult();
try
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
result.Data = action();
return result;
} }
catch (Exception ex)
//Covers also direct message
public async Task<Message> SubmitMessage(MessageForm messageForm)
{ {
//TODO await logger.ProcessError(ex, $"{nameof(MessagesService)}.{caller}", cancellationToken);
return result.Invalidate(localizer["The action could not be completed."], exception: ex);
await Task.Run(() => {});
return new();
} }
//Covers also direct message
public async Task<Message> ReplyMessage(Message messageToReply, MessageType messageType = MessageType.Public)
{
//TODO
await Task.Run(() => {});
return messageToReply;
}
public async Task<Message> BoostUnboostMessage(Message messageToReply)
{
//TODO
await Task.Run(() => {});
return messageToReply;
}
public async Task<Message> FavouriteUnfavouriteMessage(Message messageToReply)
{
//TODO
await Task.Run(() => {});
return messageToReply;
}
public async Task<Message> DeleteMessage(Message messageToReply)
{
//TODO
await Task.Run(() => {});
return messageToReply;
}
public async Task<MessageUser> BlockUserFromMessage(MessageUser messageUser)
{
//TODO
await Task.Run(() => {});
return messageUser;
}
public async Task<MessageUser> SilenceUserFromMessage(MessageUser messageUser)
{
//TODO
await Task.Run(() => {});
return messageUser;
} }
} }
} }
+55
View File
@@ -0,0 +1,55 @@
namespace decePubClient.Services;
public enum ToastSeverity
{
Info,
Success,
Warning,
Error
}
public sealed record ToastMessage(Guid Id, ToastSeverity Severity, string Text);
/// <summary>
/// The app's snackbar: components add messages (usually through the SUtility extensions AddResponseError,
/// AddResponseSuccess, AddWarning, AddException) and Components/ToastHost.razor, placed once in MainLayout, shows them.
/// A message already on screen isn't added twice, and at most MaxMessages stay, oldest dropped first.
/// </summary>
public class ToastService
{
const int MaxMessages = 12;
readonly List<ToastMessage> messages = [];
public event Action Changed;
public IReadOnlyList<ToastMessage> Messages => messages;
/// <summary>Shows a message; with closeAfter it closes itself, otherwise it stays until the user closes it.</summary>
public void Add(ToastSeverity severity, string text, TimeSpan? closeAfter = null)
{
if (string.IsNullOrWhiteSpace(text) || messages.Any(m => m.Severity == severity && m.Text == text))
return;
var message = new ToastMessage(Guid.NewGuid(), severity, text);
messages.Add(message);
if (messages.Count > MaxMessages)
messages.RemoveAt(0);
Changed?.Invoke();
if (closeAfter is { } delay)
_ = CloseLater(message.Id, delay);
}
public void Close(Guid id)
{
if (messages.RemoveAll(m => m.Id == id) > 0)
Changed?.Invoke();
}
async Task CloseLater(Guid id, TimeSpan delay, CancellationToken cancellationToken = default)
{
await Task.Delay(delay, cancellationToken);
Close(id);
}
}
+20 -33
View File
@@ -1,4 +1,5 @@
using Blazored.LocalStorage; using Blazored.LocalStorage;
using decePubClient.Helpers;
using decePubClient.Models; using decePubClient.Models;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims; using System.Security.Claims;
@@ -6,22 +7,9 @@ using System.Text.Json;
namespace decePubClient.Services namespace decePubClient.Services
{ {
public class TokenAuthStateProvider : AuthenticationStateProvider public class TokenAuthStateProvider(ILocalStorageService storage, IStorage dbStorage, ILogger<TokenAuthStateProvider> logger)
: AuthenticationStateProvider
{ {
readonly ILocalStorageService Storage;
readonly IStorage DbStorage;
readonly ILogger<TokenAuthStateProvider> Logger;
AuthData AuthData { get; set; }
public TokenAuthStateProvider(ILocalStorageService storage,
IStorage dbStorage,
ILogger<TokenAuthStateProvider> logger)
{
Storage = storage;
DbStorage = dbStorage;
Logger = logger;
}
public void SetToken(/*string token, long expirationTicks = default*/) public void SetToken(/*string token, long expirationTicks = default*/)
{ {
//AuthData = await Storage.GetItemAsync<AuthData>(nameof(AuthData)); //AuthData = await Storage.GetItemAsync<AuthData>(nameof(AuthData));
@@ -41,41 +29,40 @@ namespace decePubClient.Services
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
} }
public async ValueTask<bool> IsAuthenticatedAsync() /// <summary>The stored token while it is still valid, otherwise null.</summary>
public async Task<string> GetToken(CancellationToken cancellationToken = default)
{ {
AuthData = await Storage.GetItemAsync<AuthData>(nameof(AuthData)); var authData = await storage.GetItemAsync<AuthData>(nameof(AuthData), cancellationToken);
if (AuthData is null) if (string.IsNullOrEmpty(authData?.Token) || authData.TokenExpiration is not { } expiration || expiration <= DateTime.UtcNow.Ticks)
{ return null;
AuthData = new(); return authData.Token;
await Storage.SetItemAsync(nameof(AuthData), AuthData);
}
return AuthData.Token != null &&
AuthData.TokenExpiration.HasValue &&
AuthData.TokenExpiration.Value > DateTime.UtcNow.Ticks;
} }
public async Task LogoutAsync(bool deleteDb = false) public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default) =>
await GetToken(cancellationToken) is not null;
public async Task LogoutAsync(bool deleteDb = false, CancellationToken cancellationToken = default)
{ {
Logger.LogInformation($"set null({nameof(LogoutAsync)})"); logger.LogInformation($"set null({nameof(LogoutAsync)})");
//await Storage.RemoveItemAsync(nameof(PrivateCacheData)); //await Storage.RemoveItemAsync(nameof(PrivateCacheData));
await Storage.RemoveItemAsync(nameof(AuthData)); await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
if (deleteDb) if (deleteDb)
await DbStorage.RemoveAll(includeClientLogs: true); await dbStorage.RemoveAll(includeClientLogs: true, cancellationToken: cancellationToken);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
} }
public override async Task<AuthenticationState> GetAuthenticationStateAsync() public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{ {
AuthData = await Storage.GetItemAsync<AuthData>(nameof(AuthData)); var authData = await storage.GetItemAsync<AuthData>(nameof(AuthData));
if (string.IsNullOrEmpty(AuthData?.Token)) if (string.IsNullOrEmpty(authData?.Token))
{ {
Logger.LogInformation($"set null({nameof(GetAuthenticationStateAsync)})"); logger.LogInformation($"set null({nameof(GetAuthenticationStateAsync)})");
return new(new()); return new(new());
} }
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(ClaimTypes.UserData, JsonSerializer.Serialize(AuthData.User)) new(ClaimTypes.UserData, JsonSerializer.Serialize(authData.User, SUtility.DefaultSerializer))
}; };
//claims.Add(new(Policies.IsUser, (AuthData.User.Policies.Contains(Policies.IsUser)).ToString().ToLower())); //claims.Add(new(Policies.IsUser, (AuthData.User.Policies.Contains(Policies.IsUser)).ToString().ToLower()));
-2
View File
@@ -1,5 +1,4 @@
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
@inject NavigationManager Navigation
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
@@ -15,7 +14,6 @@
</AuthorizeView> </AuthorizeView>
@code { @code {
[CascadingParameter] CascadingState CascadingState { get; set; }
private void BeginSignOut(MouseEventArgs args) private void BeginSignOut(MouseEventArgs args)
{ {
Navigation.NavigateTo("logout"); Navigation.NavigateTo("logout");
+2
View File
@@ -9,3 +9,5 @@
</div> </div>
</div> </div>
</div> </div>
<ToastHost />
+10 -12
View File
@@ -9,19 +9,19 @@
<div class="absolute bg-base-100 md:relative top-full md:top-auto mt-2 w-52 md:w-full overflow-y-auto rounded-lg neo-raised neo-sm md:shadow-none! z-50 @menuToggle md:block"> <div class="absolute bg-base-100 md:relative top-full md:top-auto mt-2 w-52 md:w-full overflow-y-auto rounded-lg neo-raised neo-sm md:shadow-none! z-50 @menuToggle md:block">
<div class="flex flex-col gap-3 p-3 md:p-4"> <div class="flex flex-col gap-3 p-3 md:p-4">
<div class="flex gap-3 p-3 md:p-4 neo-raised neo-xs rounded-xl bg-cover bg-no-repeat bg-right" style="background-image:linear-gradient(to left, var(--color-base-100) 55%, transparent), url('@(CascadingState.User?.BackgroundUrl)');"> <div class="flex gap-3 p-3 md:p-4 neo-raised neo-xs rounded-xl bg-cover bg-no-repeat bg-right" style="background-image:linear-gradient(to left, var(--color-base-100) 55%, transparent), url('@(CascadingState.User.BackgroundUrl)');">
<a class="block h-8 w-8 md:h-12 md:w-12 flex-none" href="@CascadingState.User?.ProfileUrl" title="@CascadingState.User?.UserName"> <a class="block h-8 w-8 md:h-12 md:w-12 flex-none" href="@CascadingState.User.ProfileUrl" title="@CascadingState.User.UserName">
<img alt="@CascadingState.User?.UserName" class="h-8 w-8 md:h-12 md:w-12 object-cover rounded-full neo-raised neo-2xs" src="@(CascadingState.User?.PictureUrl ?? "/imgs/icon-192.png")" /> <img alt="@CascadingState.User.UserName" class="h-8 w-8 md:h-12 md:w-12 object-cover rounded-full neo-raised neo-2xs" src="@(CascadingState.User.PictureUrl ?? "/imgs/icon-192.png")" />
</a> </a>
<div class="flex flex-col w-full min-w-0 gap-2"> <div class="flex flex-col w-full min-w-0 gap-2">
<p class="shrink truncate"> <p class="shrink truncate">
<b title="@CascadingState.User?.DisplayName"> <b title="@CascadingState.User.DisplayName">
@CascadingState.User?.DisplayName @CascadingState.User.DisplayName
</b> </b>
</p> </p>
<p class="flex-1 min-w-6 truncate text-xs"> <p class="flex-1 min-w-6 truncate text-xs">
<a class="underline" href="@CascadingState.User?.ProfileUrl" title="@CascadingState.User?.UserName"> <a class="underline" href="@CascadingState.User.ProfileUrl" title="@CascadingState.User.UserName">
@CascadingState.User?.UserName @CascadingState.User.UserName
</a> </a>
</p> </p>
</div> </div>
@@ -94,21 +94,19 @@
<div class="md:hidden"> <div class="md:hidden">
<a class="block rounded-full neo-raised neo-2xs" href="settings"> <a class="block rounded-full neo-raised neo-2xs" href="settings">
<img alt="@CascadingState.User?.UserName" class="object-cover w-[30px] h-[30px] rounded-full" <img alt="@CascadingState.User.UserName" class="object-cover w-[30px] h-[30px] rounded-full"
src="@CascadingState.User?.PictureUrl"> src="@CascadingState.User.PictureUrl">
</a> </a>
</div> </div>
</nav> </nav>
@code { @code {
[CascadingParameter] CascadingState CascadingState { get; set; }
string menuToggle = "hidden"; string menuToggle = "hidden";
PageSettings Settings => CascadingState.PublicCacheData?.PageSettings ?? new();
// The toggle and the slider work on the variant that is showing; with system theming that is the OS's choice. // The toggle and the slider work on the variant that is showing; with system theming that is the OS's choice.
bool IsDark => CascadingState.Status.IsDarkTheme(); bool IsDark => CascadingState.Status.IsDarkTheme();
short ThemeIndexColour => IsDark ? Settings.DarkThemeIndexColour : Settings.LightThemeIndexColour; short ThemeIndexColour => IsDark ? CascadingState.PublicCacheData.PageSettings.DarkThemeIndexColour : CascadingState.PublicCacheData.PageSettings.LightThemeIndexColour;
protected override void OnInitialized() protected override void OnInitialized()
{ {
+2 -2
View File
@@ -1,6 +1,6 @@
@inject NavigationManager Navigation @code {
[Inject] NavigationManager Navigation { get; set; }
@code {
protected override void OnInitialized() protected override void OnInitialized()
{ {
Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}"); Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");
+1
View File
@@ -19,6 +19,7 @@
@using Markdig @using Markdig
@using decePubClient @using decePubClient
@using decePubClient.Services @using decePubClient.Services
@using collAnon.Client.Services
@using decePubClient.Shared @using decePubClient.Shared
@using decePubClient.Models @using decePubClient.Models
@using decePubClient.Models.Types @using decePubClient.Models.Types