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:
1 parent
ca060f25fb
commit
95428430c0
55 files changed
+1755
-1248
No files matched your search
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace decePubClient.Components;
|
||||||
|
|
||||||
|
public enum ErrorViewerSeverity
|
||||||
|
{
|
||||||
|
Error,
|
||||||
|
Warning
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -177,5 +177,4 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter] CascadingState CascadingState { get; set; }
|
|
||||||
}
|
}
|
||||||
+93
-94
@@ -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
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,5 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter] CascadingState CascadingState { get; set; }
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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>()
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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<WebResult> 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -9,3 +9,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ToastHost />
|
||||||
+10
-12
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)}");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user