From 95428430c007341a5777b7760fc1d29add54b7d7 Mon Sep 17 00:00:00 2001
From: thepra
Date: Sat, 3 Oct 2026 15:26:21 +0200
Subject: [PATCH] Adopt b2b.next's client patterns: services, page state,
feedback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 / Task>.
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 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
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
---
App.razor | 12 +-
ClientJsonContext.cs | 36 ++
Components/ActionBar.razor | 15 +-
Components/Content.razor | 42 +-
Components/Daisy/DataDisplay/DAccordion.razor | 4 +-
Components/Daisy/DataDisplay/DAvatar.razor | 1 +
Components/Daisy/DataDisplay/DCarousel.razor | 6 +-
.../Daisy/DataDisplay/DHoverGallery.razor | 1 +
Components/Daisy/Navigation/DTabs.razor | 4 +-
Components/ErrorViewer.razor | 58 +++
Components/ErrorViewerSeverity.cs | 7 +
Components/MessageUpsertForm.razor | 236 ++++-----
Components/Styleguide/DataInputSection.razor | 56 +--
Components/Styleguide/FeedbackSection.razor | 2 +-
Components/Styleguide/LayoutSection.razor | 2 +-
Components/ToastHost.razor | 41 ++
Extensions/ExtensionMethods.cs | 113 +++--
Extensions/GenericExtensions.cs | 33 +-
Extensions/WebResultExtensions.cs | 12 +
Helpers/Faker.cs | 258 +++++++++-
Helpers/SUtility.cs | 98 +++-
LayerComponents/CascadingState.razor | 151 +++---
LayerComponents/LocalizableComponentBase.cs | 103 +++-
Models/BoostingUser.cs | 10 +-
Models/ClientLogs.cs | 20 +-
Models/FailureCodes.cs | 20 +
Models/Media.cs | 2 +-
Models/Message.cs | 2 +-
Models/MessageForm.cs | 2 +-
Models/MessageUser.cs | 12 +-
Models/PublicCacheData.cs | 6 +-
Models/UploadMedia.cs | 2 +-
Models/User.cs | 33 +-
Models/ValidationErrors.cs | 11 +
Pages/Administration.razor | 1 -
Pages/ExpandMessage.razor | 219 +++++----
Pages/Index.razor | 240 ++++------
Pages/Login.razor | 1 -
Pages/Logout.razor | 2 -
Pages/Settings.razor | 31 +-
Program.cs | 8 +-
Services/AppStatusService.cs | 34 +-
Services/CoalescingStringLocalizer.cs | 75 +--
Services/IHttpService.cs | 343 ++++++-------
Services/ILoggingService.cs | 61 +++
Services/IStorage.cs | 452 +++++-------------
Services/IndexedDb.cs | 8 +-
Services/MessagesService.cs | 144 +++---
Services/ToastService.cs | 55 +++
Services/TokenAuthStateProvider.cs | 53 +-
Shared/LoginDisplay.razor | 2 -
Shared/MainLayout.razor | 4 +-
Shared/NavMenu.razor | 22 +-
Shared/RedirectToLogin.razor | 14 +-
_Imports.razor | 1 +
55 files changed, 1844 insertions(+), 1337 deletions(-)
create mode 100644 ClientJsonContext.cs
create mode 100644 Components/ErrorViewer.razor
create mode 100644 Components/ErrorViewerSeverity.cs
create mode 100644 Components/ToastHost.razor
create mode 100644 Extensions/WebResultExtensions.cs
create mode 100644 Models/FailureCodes.cs
create mode 100644 Models/ValidationErrors.cs
create mode 100644 Services/ILoggingService.cs
create mode 100644 Services/ToastService.cs
diff --git a/App.razor b/App.razor
index 56aa443..abb2f19 100644
--- a/App.razor
+++ b/App.razor
@@ -10,18 +10,22 @@
}
else
{
- You are not authorized to access this resource.
+ @Localizer["You are not authorized to access this resource."]
}
- Not found
+ @Localizer["Not found"]
- Sorry, there's nothing at this address.
+ @Localizer["Sorry, there's nothing at this address."]
-
\ No newline at end of file
+
+
+@code {
+ [Inject] CoalescingStringLocalizer Localizer { get; set; }
+}
diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs
new file mode 100644
index 0000000..3b27a6a
--- /dev/null
+++ b/ClientJsonContext.cs
@@ -0,0 +1,36 @@
+using System.Text.Json.Serialization;
+
+using decePubClient.Models;
+
+using PrivaPub.ClientModels;
+
+namespace decePubClient;
+
+///
+/// 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.
+///
+[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))]
+[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
+{
+}
diff --git a/Components/ActionBar.razor b/Components/ActionBar.razor
index 30f823d..ccde121 100644
--- a/Components/ActionBar.razor
+++ b/Components/ActionBar.razor
@@ -65,33 +65,38 @@
@code {
- [CascadingParameter] CascadingState CascadingState { get; set; }
[Parameter] public EventCallback OnMessageSubmit { get; set; }
[Parameter] public EventCallback OnTimelineChanged { get; set; }
[Parameter] public EventCallback OnTimeSortingChanged { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
- bool isPosting { get; set; } = false;
+ bool isPosting;
ActionBarFilter Filters { get; set; } = new();
protected override async Task OnInitializedAsync()
{
var filters = await LocalStorage.GetItemAsync(nameof(ActionBarFilter));
- if (filters == default)
- await LocalStorage.SetItemAsync(nameof(ActionBarFilter), Filters);
- else
+ IsLoading = false;
+ if (filters is not null)
+ {
Filters = filters;
+ return;
+ }
+
+ await LocalStorage.SetItemAsync(nameof(ActionBarFilter), Filters);
}
void OpenCloseMessageForm()
{
isPosting = !isPosting;
}
+
async Task OnTimelineChange(TimelineType timelineType)
{
Filters.TimelineType = timelineType;
await LocalStorage.SetItemAsync(nameof(ActionBarFilter), Filters);
await OnTimelineChanged.InvokeAsync(Filters.TimelineType);
}
+
async Task OnTimeSortChange(TimeSortingType timeSorting)
{
Filters.TimeSortingType = timeSorting;
diff --git a/Components/Content.razor b/Components/Content.razor
index e4b51fa..c0fd16c 100644
--- a/Components/Content.razor
+++ b/Components/Content.razor
@@ -3,7 +3,7 @@
+ style="background-image:linear-gradient(to left, var(--color-base-100), transparent), url('@(Message.User.BackgroundUrl)');">
@@ -32,8 +32,8 @@
-
- @Message.CreatedAt.GetPassedTime(Localizer._pLocalizer)
+
+ @Message.CreatedAt.GetPassedTime(Localizer)
@@ -104,13 +104,13 @@
@if (OnMessageReply.HasDelegate)
{
-
+
}
@if (OnMessageBoost.HasDelegate)
{
-
@if (Message.BoostsCounter != 0)
@@ -121,7 +121,7 @@
}
@if (OnMessageFavourite.HasDelegate)
{
-
@@ -138,28 +138,29 @@
@if (OnUserDirectMessage.HasDelegate)
{
-
+
@Localizer["Direct message"]
}
@if (OnUserSilence.HasDelegate)
{
-
+
@Localizer["Mute"]
}
@if (OnUserBlock.HasDelegate)
{
-
+
@Localizer["Block"]
}
- @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)
{
-
+
@Localizer["Delete"]
@@ -184,8 +185,6 @@
@code {
- [CascadingParameter] Task AuthState { get; set; }
- [CascadingParameter] CascadingState CascadingState { get; set; }
[Parameter] public Message Message { get; set; } = new();
[Parameter] public EventCallback OnMessageReply { get; set; }
[Parameter] public EventCallback OnMessageBoost { get; set; }
@@ -200,15 +199,9 @@
[Parameter] public bool Highlighted { get; set; }
[Parameter] public bool IncludeExpand { get; set; } = true;
- bool isAnswering { get; set; } = false;
+ bool isAnswering;
- string CurrentUserName
- {
- get
- {
- return AuthState.Result.User.Identity?.Name;
- }
- }
+ protected override void OnInitialized() => IsLoading = false;
async Task DeleteMessage()
{
@@ -222,11 +215,8 @@
await OnMessageReply.InvokeAsync(messageForm);
}
- async Task Reply()
+ void Reply()
{
- await Task.Run(() =>
- {
- });
isAnswering = !isAnswering;
}
-}
\ No newline at end of file
+}
diff --git a/Components/Daisy/DataDisplay/DAccordion.razor b/Components/Daisy/DataDisplay/DAccordion.razor
index 4b0bb7a..67e51e9 100644
--- a/Components/Daisy/DataDisplay/DAccordion.razor
+++ b/Components/Daisy/DataDisplay/DAccordion.razor
@@ -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
index in C# (@bind-Active, -1 = none). Each DAccordionItem registers itself through the cascading value and renders a
@@ -19,7 +19,7 @@
/// Join the items into one block (join join-vertical, join-item).
[Parameter] public bool Joined { get; set; }
- readonly List items = new();
+ readonly List items = [];
string CssClass => Cx(Joined ? "join join-vertical rounded-box neo-raised" : "flex flex-col gap-2", Class);
diff --git a/Components/Daisy/DataDisplay/DAvatar.razor b/Components/Daisy/DataDisplay/DAvatar.razor
index 3d7a73b..1a6caf5 100644
--- a/Components/Daisy/DataDisplay/DAvatar.razor
+++ b/Components/Daisy/DataDisplay/DAvatar.razor
@@ -18,6 +18,7 @@
@code {
[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; } = "";
/// Shown in the frame when there is no Src, e.g. initials with Placeholder.
[Parameter] public RenderFragment ChildContent { get; set; }
diff --git a/Components/Daisy/DataDisplay/DCarousel.razor b/Components/Daisy/DataDisplay/DCarousel.razor
index e6ba869..baa00a1 100644
--- a/Components/Daisy/DataDisplay/DCarousel.razor
+++ b/Components/Daisy/DataDisplay/DCarousel.razor
@@ -1,5 +1,4 @@
-@inherits DComponentBase
-@inject NavigationManager NavigationManager
+@inherits DComponentBase
@* 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
@@ -25,6 +24,7 @@
@code {
+ [Inject] NavigationManager NavigationManager { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public DCarouselSnap Snap { get; set; }
[Parameter] public DDirection Direction { get; set; }
@@ -36,7 +36,7 @@
[Parameter] public string NextText { get; set; } = "Next slide";
[Parameter] public string SlideText { get; set; } = "Slide";
- readonly List items = new();
+ readonly List items = [];
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
RenderFragment Strip => @@ChildContent
;
diff --git a/Components/Daisy/DataDisplay/DHoverGallery.razor b/Components/Daisy/DataDisplay/DHoverGallery.razor
index f49fc93..cbccf36 100644
--- a/Components/Daisy/DataDisplay/DHoverGallery.razor
+++ b/Components/Daisy/DataDisplay/DHoverGallery.razor
@@ -16,6 +16,7 @@
@code {
[Parameter] public IEnumerable 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 RenderFragment ChildContent { get; set; }
}
diff --git a/Components/Daisy/Navigation/DTabs.razor b/Components/Daisy/Navigation/DTabs.razor
index ed95073..98d9ce7 100644
--- a/Components/Daisy/Navigation/DTabs.razor
+++ b/Components/Daisy/Navigation/DTabs.razor
@@ -1,4 +1,4 @@
-@inherits DComponentBase
+@inherits DComponentBase
@* 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). *@
@@ -35,7 +35,7 @@
[Parameter] public bool Bottom { get; set; }
[Parameter] public string ContentClass { get; set; } = "pt-4";
- readonly List tabs = new();
+ readonly List tabs = [];
string CssClass => Cx("tabs", VariantClass, SizeClass, Bottom ? "tabs-bottom" : null, Class);
diff --git a/Components/ErrorViewer.razor b/Components/ErrorViewer.razor
new file mode 100644
index 0000000..1b403c4
--- /dev/null
+++ b/Components/ErrorViewer.razor
@@ -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 each.
+ Renders nothing when there is nothing to show. Messages are text, not markup: they can contain file names. *@
+@if (HasContent)
+{
+
+
+ @foreach (var message in Messages)
+ {
+ @message
+ }
+
+
+}
+
+@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 AdditionalErrors { get; set; } = [];
+
+ EditContext subscribedEditContext;
+
+ IEnumerable 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();
+}
diff --git a/Components/ErrorViewerSeverity.cs b/Components/ErrorViewerSeverity.cs
new file mode 100644
index 0000000..e1e77e9
--- /dev/null
+++ b/Components/ErrorViewerSeverity.cs
@@ -0,0 +1,7 @@
+namespace decePubClient.Components;
+
+public enum ErrorViewerSeverity
+{
+ Error,
+ Warning
+}
diff --git a/Components/MessageUpsertForm.razor b/Components/MessageUpsertForm.razor
index b6f89b2..d9395d1 100644
--- a/Components/MessageUpsertForm.razor
+++ b/Components/MessageUpsertForm.razor
@@ -4,15 +4,15 @@
-
- @(MessageForm.Content?.Length ?? 0)/5000
+ @ContentLength/@MaxContentLength
@if (showPreviewButton && isPreviewOpen && MessageForm.Content is { Length: > 0 })
{
@@ -30,10 +30,6 @@
}
-
-
- @contentError
-
-
@@ -74,7 +70,7 @@
@media.FileName
- @media.ContentType @media.Size.GetFileSize(Localizer._pLocalizer)
+ @media.ContentType @media.Size.GetFileSize(Localizer)
@@ -92,7 +88,7 @@
@media.FileName
- @media.ContentType @media.Size.GetFileSize(Localizer._pLocalizer)
+ @media.ContentType @media.Size.GetFileSize(Localizer)
@@ -108,19 +104,14 @@
- @if (fileInputErrorMessage is { Length: > 0 })
- {
-
- @((MarkupString)fileInputErrorMessage)
-
- }
+
+ title="@Localizer["Message scope type: {0}", Localizer[MessageForm.MessageType.ToString()]]">
@@ -180,9 +171,6 @@
}
-
-
-
@if (showPreviewButton)
@@ -194,7 +182,7 @@
-
+
@Localizer["Post"]
@@ -204,16 +192,46 @@
@code {
- [CascadingParameter] CascadingState CascadingState { get; set; }
+ const int MaxContentLength = 5_000;
+
+ [Inject] ILoggingService Logger { get; set; }
[Parameter] public Message AnsweringMessage { get; set; }
[Parameter] public EventCallback OnMessageSubmit { get; set; }
MessageForm MessageForm { get; set; } = new();
- int totalCharacters { get; set; } = 0;
- string fileInputErrorMessage { get; set; }
- string contentError { get; set; }
- string acceptedFilesTypes { get; set; } = ".jpg,.jpeg,.png,.gif";
- bool showPreviewButton { get; set; } = false;
- bool isPreviewOpen { get; set; } = false;
+ List fileErrors = [];
+ string contentError;
+ string acceptedFilesTypes = ".jpg,.jpeg,.png,.gif";
+ bool showPreviewButton;
+ bool isPreviewOpen;
+
+ // 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)
{
@@ -221,114 +239,68 @@
MessageForm.IsScopeOptionsOpen = false;
}
- protected override void OnInitialized()
- {
- if (AnsweringMessage != default)
- {
- MessageForm.Title = AnsweringMessage.Title;
- MessageForm.RootMessageId = AnsweringMessage.RootMessageId ?? AnsweringMessage.MessageId;
- }
- }
+ bool ShouldDisableUpload() => MessageForm.Media.Count >= MaximumFileCount;
- bool ShouldDisableUpload()
- {
- 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;
- }
+ bool ShouldHaveMultipleUpload() => MessageForm.MediaType is MediaType.Images or MediaType.Documents;
async Task OnFileChange(InputFileChangeEventArgs eventArgs)
+ {
+ fileErrors = [];
+ var maximumFileCount = MaximumFileCount;
+ if (eventArgs.FileCount + MessageForm.Media.Count > maximumFileCount)
+ {
+ fileErrors.Add(Localizer["The maximum number of files accepted is {0}, but {1} were supplied.", maximumFileCount,
+ MessageForm.Media.Count + eventArgs.FileCount]);
+ return;
+ }
+
+ var maximumFileSize = MaximumFileSize;
+ foreach (var file in eventArgs.GetMultipleFiles(maximumFileCount))
+ {
+ if (file.Name == default || file.ContentType == default || MessageForm.Media.Any(m => m.FileName == file.Name))
+ continue;
+
+ if (file.Size > maximumFileSize)
+ {
+ fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
+ file.Size.GetFileSize(Localizer), maximumFileSize.GetFileSize(Localizer)]);
+ continue;
+ }
+
+ var uploadMedia = await ReadFile(file, maximumFileSize);
+ if (uploadMedia is null)
+ {
+ fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
+ continue;
+ }
+
+ MessageForm.Media.Add(uploadMedia);
+ }
+ }
+
+ /// Reads a picked file into memory; null when the browser can't read it (logged).
+ async Task ReadFile(IBrowserFile file, long maximumFileSize, CancellationToken cancellationToken = default)
{
try
{
- fileInputErrorMessage = string.Empty;
- var maximumFileCount = MessageForm.MediaType switch
+ using var memoryStream = new MemoryStream();
+ await using var fileStream = file.OpenReadStream(maximumFileSize, cancellationToken);
+ await fileStream.CopyToAsync(memoryStream, cancellationToken);
+ var uploadMedia = new UploadMedia
{
- MediaType.Images => 5,
- MediaType.Audio => 1,
- MediaType.Video => 1,
- MediaType.Documents => 3,
- _ => 0
+ FileName = file.Name,
+ ContentType = file.ContentType,
+ Size = file.Size,
+ Blob = memoryStream.ToArray()
};
- 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)
- {
- fileInputErrorMessage = string.Format(Localizer["The maximum number of files accepted is {0}, but {1} were supplied."], maximumFileCount, $"{MessageForm.Media.Count}+{eventArgs.FileCount}");
- return;
- }
- var maxAllowedSize = MessageForm.MediaType switch
- {
- 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))
- {
- if (file.Name == default || file.ContentType == default) continue;
- if (MessageForm.Media.Any(m => m.FileName == file.Name)) continue;
- if (file.Size > maxAllowedSize)
- {
- 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) + " ";
- continue;
- }
-
- uploadMedia = new()
- {
- FileName = 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;
- }
- 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);
- }
+ if (MessageForm.MediaType is MediaType.Images or MediaType.Audio)
+ uploadMedia.Base64Preview = $"data:{uploadMedia.ContentType};base64,{Convert.ToBase64String(uploadMedia.Blob)}";
+ return uploadMedia;
}
- catch (Exception e)
+ catch (Exception ex)
{
- fileInputErrorMessage = e.Message;
+ await Logger.ProcessError(ex, $"{nameof(MessageUpsertForm)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
+ return default;
}
}
@@ -342,12 +314,6 @@
MessageForm.Title = value;
}
- void ContentLengthChanged()
- {
- totalCharacters = MessageForm.Content?.Length ?? 0;
- StateHasChanged();
- }
-
void OnContentTypeChanged(ContentType contentType)
{
MessageForm.ContentType = contentType;
@@ -375,7 +341,7 @@
async Task OnValidSubmit()
{
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"];
return;
@@ -383,4 +349,4 @@
await OnMessageSubmit.InvokeAsync(MessageForm);
}
-}
\ No newline at end of file
+}
diff --git a/Components/Styleguide/DataInputSection.razor b/Components/Styleguide/DataInputSection.razor
index 5f81ecb..60c0ded 100644
--- a/Components/Styleguide/DataInputSection.razor
+++ b/Components/Styleguide/DataInputSection.razor
@@ -11,31 +11,31 @@
@foreach (var color in Enum.GetValues())
{
-
+
}
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
⌘ K
-
+
https://
.com
Immediate search: "@search", text: "@text"
-
+
Name is required
@@ -82,22 +82,22 @@
@foreach (var color in Enum.GetValues())
{
-
+
}
-
-
-
-
-
+
+
+
+
+
-
-
+
+
-
+
Between 10 and 140 characters
@(model.Bio?.Length ?? 0)/140
@@ -286,21 +286,21 @@
Page details
Title
-
+
Slug
-
+
You can edit these later
Disabled fieldset
-
+
-
+
-
+
@@ -308,11 +308,11 @@
DValidator / DValidatorHint
Native rules, checked by the browser once you've edited the field.
-
+
Enter a valid email address
-
Must be 3 to 30 letters, numbers or dashes (Compact: no space while valid)
@@ -375,7 +375,7 @@
string framework;
DColor? filterColor;
string otp;
- List files = new();
+ List files = [];
void Submitted(bool valid) => submitResult = valid ? "Valid" : "Invalid: see the messages above";
diff --git a/Components/Styleguide/FeedbackSection.razor b/Components/Styleguide/FeedbackSection.razor
index a8f8f4e..486426b 100644
--- a/Components/Styleguide/FeedbackSection.razor
+++ b/Components/Styleguide/FeedbackSection.razor
@@ -216,7 +216,7 @@
record Toast(int Id, DAlertColor Color);
- readonly List toasts = new();
+ readonly List toasts = [];
DToastHorizontal toastHorizontal;
DToastVertical toastVertical;
int toastCount;
diff --git a/Components/Styleguide/LayoutSection.razor b/Components/Styleguide/LayoutSection.razor
index 4f17331..529e916 100644
--- a/Components/Styleguide/LayoutSection.razor
+++ b/Components/Styleguide/LayoutSection.razor
@@ -165,7 +165,7 @@
from lg
-
+
Filter
Sci-fi
diff --git a/Components/ToastHost.razor b/Components/ToastHost.razor
new file mode 100644
index 0000000..17fc2ad
--- /dev/null
+++ b/Components/ToastHost.razor
@@ -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)
+{
+
+ @foreach (var message in Toast.Messages)
+ {
+
+
+ @message.Text
+
+
+
+
+
+
+
+ }
+
+}
+
+@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;
+}
diff --git a/Extensions/ExtensionMethods.cs b/Extensions/ExtensionMethods.cs
index 10222bc..dce55fa 100644
--- a/Extensions/ExtensionMethods.cs
+++ b/Extensions/ExtensionMethods.cs
@@ -2,6 +2,7 @@
using collAnon.Client.Services;
+using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Services;
@@ -16,6 +17,8 @@ using PrivaPub.ClientModels;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
+using System.Net;
+using System.Net.Http.Json;
using System.Text.Json;
using System.Web;
@@ -34,53 +37,47 @@ namespace decePubClient.Extensions
public static T QueryString(this NavigationManager navigationManager, string key)
{
var value = navigationManager.QueryString()[key];
- var converter = TypeDescriptor.GetConverter(typeof(T));
- if (converter.IsValid(value))
- return (T)Convert.ChangeType(value, typeof(T));
- else
+ if (!TypeDescriptor.GetConverter(typeof(T)).IsValid(value))
return default;
+ return (T)Convert.ChangeType(value, typeof(T));
}
#endregion Navigation Manager
#region Setups
- public static async Task SetDefaultCulture(this WebAssemblyHost host)
+ /// The languages with resources (Resources/AllStrings.*.resx); any other browser language gets English.
+ 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
{
var storage = host.Services.GetRequiredService();
- var publicCache = await storage.GetItemAsync(nameof(PublicCacheData));
+ var publicCache = await storage.GetItemAsync(nameof(PublicCacheData), cancellationToken);
if (publicCache is null)
{
+ var browserLanguage = host.Services.GetRequiredService().Language();
publicCache = new();
- var status = host.Services.GetRequiredService();
- var browserLanguage = status.Language();
- if (browserLanguage.Contains("en"))
- browserLanguage = "en-GB";
- var appConfig = host.Services.GetRequiredService();
- 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);
+ publicCache.PageSettings.CurrentLanguageCode =
+ SupportedLanguages.FirstOrDefault(language => browserLanguage?.StartsWith(language) ?? false) ?? "en";
+ await storage.SetItemAsync(nameof(PublicCacheData), publicCache, cancellationToken);
}
- culture ??= new(publicCache.PageSettings.CurrentLanguageCode == "en" ? "en-GB" : publicCache.PageSettings.CurrentLanguageCode);
- CultureInfo.DefaultThreadCurrentCulture = culture;
- CultureInfo.DefaultThreadCurrentUICulture = culture;
+ culture = ToCulture(publicCache.PageSettings.CurrentLanguageCode);
}
catch (Exception ex)
{
Console.WriteLine(ex);
- culture = new("en-GB");
- CultureInfo.DefaultThreadCurrentCulture = culture;
- CultureInfo.DefaultThreadCurrentUICulture = culture;
}
+
+ CultureInfo.DefaultThreadCurrentCulture = culture;
+ CultureInfo.DefaultThreadCurrentUICulture = culture;
}
+ static CultureInfo ToCulture(string languageCode) => languageCode is "it" ? new("it-IT") : new("en-GB");
+
#endregion Setups
public static string GetLogLevelClass(this string logType) => logType switch
@@ -112,6 +109,64 @@ namespace decePubClient.Extensions
public static string ToString(this WebResult webResult, CoalescingStringLocalizer localizer) =>
localizer["{0} - {1}", webResult.StatusCode, webResult.ErrorMessage];
+ ///
+ /// 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.
+ ///
+ public static async Task 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(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(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(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);
+ }
+ }
+
+ /// Reads a successful response body; a cancelled read returns SUtility.CancellationDefault.
+ public static async Task DefaultReadFromJsonAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ return await response.Content.ReadFromJsonAsync(SUtility.DefaultSerializer, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return SUtility.CancellationDefault();
+ }
+ }
+
public static string Timeframe(this DateTime creationDate, CoalescingStringLocalizer localizer)
{
var timeframe = DateTime.Now - creationDate.ToLocalTime();
@@ -133,19 +188,19 @@ namespace decePubClient.Extensions
public static AuthorizationPolicy IsAdminPolicy() =>
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
- .RequireClaim(Policies.IsAdmin, true.ToString().ToLower())
- .RequireClaim(Policies.IsUser, true.ToString().ToLower())
- .RequireClaim(Policies.IsModerator, true.ToString().ToLower())
+ .RequireClaim(Policies.IsAdmin, "true")
+ .RequireClaim(Policies.IsUser, "true")
+ .RequireClaim(Policies.IsModerator, "true")
.Build();
public static AuthorizationPolicy IsUserPolicy() =>
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
- .RequireClaim(Policies.IsUser, true.ToString().ToLower())
+ .RequireClaim(Policies.IsUser, "true")
.Build();
public static AuthorizationPolicy IsUserModeratorPolicy() =>
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
- .RequireClaim(Policies.IsModerator, true.ToString().ToLower())
+ .RequireClaim(Policies.IsModerator, "true")
.Build();
#endregion
diff --git a/Extensions/GenericExtensions.cs b/Extensions/GenericExtensions.cs
index 8ad1e9c..a791c35 100644
--- a/Extensions/GenericExtensions.cs
+++ b/Extensions/GenericExtensions.cs
@@ -1,4 +1,5 @@
-using System.Collections.Specialized;
+using collAnon.Client.Services;
+using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
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();
- switch ((int)timeframe.TotalHours)
+ return (int)timeframe.TotalHours switch
{
- case >= 24:
- return string.Format(localizer["{0}d"], (int)timeframe.TotalDays);
- case >= 1 and < 24:
- return string.Format(localizer["{0}h"], (int)timeframe.TotalHours);
- case 0:
- return string.Format(localizer["{0}m"], (int)timeframe.TotalMinutes);
- default:
- return default;
- }
+ >= 24 => localizer["{0}d", (int)timeframe.TotalDays],
+ >= 1 => localizer["{0}h", (int)timeframe.TotalHours],
+ _ => localizer["{0}m", (int)timeframe.TotalMinutes]
+ };
}
- public static string GetFileSize(this long size, IStringLocalizer localizer)
+ public static string GetFileSize(this long size, CoalescingStringLocalizer localizer) => size switch
{
- switch (size)
- {
- 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);
- }
- }
+ >= 1_048_576 => localizer["{0}MB", size / 1_048_576],
+ _ => localizer["{0}KB", size / 1_024]
+ };
public static string ParseContent(this string content, ContentType contentType)
{
diff --git a/Extensions/WebResultExtensions.cs b/Extensions/WebResultExtensions.cs
new file mode 100644
index 0000000..c76fdac
--- /dev/null
+++ b/Extensions/WebResultExtensions.cs
@@ -0,0 +1,12 @@
+using PrivaPub.ClientModels;
+
+namespace decePubClient.Extensions;
+
+public static class WebResultExtensions
+{
+ extension(WebResult result)
+ {
+ /// The early-return guard (b2b.next's WebResult.IsInvalid, which PrivaPub's WebResult lacks).
+ public bool IsInvalid => !result.IsValid;
+ }
+}
diff --git a/Helpers/Faker.cs b/Helpers/Faker.cs
index 3ab5709..fe693b7 100644
--- a/Helpers/Faker.cs
+++ b/Helpers/Faker.cs
@@ -1,12 +1,29 @@
using decePubClient.Models;
+using decePubClient.Models.Types;
+
+using Markdig;
namespace decePubClient.Helpers;
+///
+/// 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.
+///
public static class Faker
{
- static IReadOnlyList Users => new List
+ 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 Users =>
+ [
+ CurrentUser.ToMessageUser(),
new()
{
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",
ProfileUrl = "https://mastodon.uno/web/@valenappi@beta.birdsite.live"
}
- };
+ ];
public static MessageUser GetRandomUser()
{
@@ -44,4 +61,237 @@ public static class Faker
ProfileUrl = user.ProfileUrl
};
}
-}
\ No newline at end of file
+
+ ///
+ /// 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).
+ ///
+ public static List SeedMessages()
+ {
+ var messages = new List();
+ 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;
+ }
+
+ /// The messages before and after an expanded one.
+ public static (List Ancestors, List 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 = "esempio di messaggio precedente a quello espanso
",
+ 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 = "esempio di messaggio successivo a quello espanso
",
+ 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,
+ }
+ ]
+ );
+}
diff --git a/Helpers/SUtility.cs b/Helpers/SUtility.cs
index 40aaf7b..f3b8557 100644
--- a/Helpers/SUtility.cs
+++ b/Helpers/SUtility.cs
@@ -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
{
public static class SUtility
{
- public static T IfTrueThen(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";
+
+ /// Marks a response HttpService made up itself (a classified failure), not one the server sent.
+ public const string FailureHeader = "X-DecePub-Failure";
+
+ /// The JSON options for every HTTP payload: web defaults, ClientJsonContext first, reflection after.
+ public static JsonSerializerOptions DefaultSerializer { get; } = new(JsonSerializerDefaults.Web)
{
- if (conditionIsTrue)
- return ifTrue;
- else
- return ifFalse;
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(ClientJsonContext.Default, new DefaultJsonTypeInfoResolver())
+ };
+
+ /// What a cancelled request turns into: HTTP 410, which AddResponseError ignores.
+ 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);
+
+ /// A response carrying an invalid WebResult, tagged with FailureHeader so ReadWebResult reads it as is.
+ 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 };
}
+ /// The value a cancelled read returns: default for value types, an empty list for list interfaces.
+ public static T CancellationDefault()
+ {
+ 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();
+ }
+
+ #region Toasts
+
+ /// Shows a failed result: connectivity failures as warnings, the rest as errors; cancellations not at all.
+ 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(bool conditionIsTrue, T ifTrue, T ifFalse = default) =>
+ conditionIsTrue ? ifTrue : ifFalse;
+
public static bool TimeBetween(this DateTime datetime, TimeSpan start, TimeSpan end)
{
// convert datetime to a TimeSpan
@@ -65,4 +149,4 @@ namespace decePubClient.Helpers
}
}
}
-}
\ No newline at end of file
+}
diff --git a/LayerComponents/CascadingState.razor b/LayerComponents/CascadingState.razor
index bf82ecf..f402177 100644
--- a/LayerComponents/CascadingState.razor
+++ b/LayerComponents/CascadingState.razor
@@ -1,138 +1,111 @@
-
+@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. *@
+
@ChildContent
@code {
[Parameter] public RenderFragment ChildContent { get; set; }
- [Inject] IStorage DbStorage { get; set; }
[Inject] ILocalStorageService Storage { get; set; }
[Inject] IJSRuntime JS { get; set; }
- // [Inject] DataService DataService { get; set; }
[Inject] public AppStatusService Status { get; set; }
- [Inject] ILogger Logger { get; set; }
+ [Inject] ILoggingService Logger { get; set; }
+ [Inject] ToastService Toast { get; set; }
- public bool IsOnline { get; set; } = true;
- Timer IsOnlineTimer { get; set; }
- public PublicCacheData PublicCacheData { get; set; }
- public User User { get; set; }
+ public bool IsOnline { get; private set; } = true;
+ public bool IsOffline => !IsOnline;
+ public PublicCacheData PublicCacheData { get; private set; } = new();
+ /// The signed-in user; Faker's until the login is wired to PrivaPub.
+ public User User { get; private set; } = Faker.CurrentUser;
DotNetObjectReference cascadingStateReference;
+ PeriodicTimer onlineTimer;
+ readonly CancellationTokenSource disposeCancellation = new();
protected override async Task OnInitializedAsync()
{
+ var cancellationToken = disposeCancellation.Token;
try
{
- IsOnlineTimer = new Timer(async _ => await UpdateIsOnline(), new AutoResetEvent(false), 0, 10000);
cascadingStateReference = DotNetObjectReference.Create(this);
- await JS.InvokeVoidAsync("cascadingStateInstanceReference", cascadingStateReference);
- PublicCacheData = await Storage.GetItemAsync(nameof(PublicCacheData));
- if (PublicCacheData == null)
- {
- PublicCacheData = new();
- await UpdatePublicCache(PublicCacheData);
- }
- else
- Status.ApplyTheme(PublicCacheData.PageSettings);
+ await JS.InvokeVoidAsync("cascadingStateInstanceReference", cancellationToken, cascadingStateReference);
- User = new();
+ var publicCacheData = await Storage.GetItemAsync(nameof(PublicCacheData), cancellationToken);
+ await UpdatePublicCache(publicCacheData ?? new(), cancellationToken);
+
+ IsOnline = await Status.IsOnline(cancellationToken);
+ onlineTimer = new(TimeSpan.FromSeconds(10));
+ _ = PollOnline(cancellationToken);
}
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)
+ /// Saves the public cache, applies its theme and re-renders the app.
+ public async ValueTask UpdatePublicCache(PublicCacheData publicCacheData, CancellationToken cancellationToken = default)
{
- try
- {
- PublicCacheData = publicCacheData;
- await Storage.SetItemAsync(nameof(PublicCacheData), PublicCacheData);
- Status.ApplyTheme(PublicCacheData.PageSettings);
- StateHasChanged();
- }
- catch (Exception ex)
- {
- Console.WriteLine($"{nameof(CascadingState)}.{nameof(UpdatePublicCache)}");
- Console.WriteLine(ex.ToString());
- }
+ PublicCacheData = publicCacheData;
+ await Storage.SetItemAsync(nameof(PublicCacheData), PublicCacheData, cancellationToken);
+ Status.ApplyTheme(PublicCacheData.PageSettings);
+ StateHasChanged();
}
/// Changes the page settings, then saves and applies them like UpdatePublicCache.
- public ValueTask UpdatePageSettings(Action change)
+ public ValueTask UpdatePageSettings(Action change, CancellationToken cancellationToken = default)
{
change(PublicCacheData.PageSettings);
- return UpdatePublicCache(PublicCacheData);
+ return UpdatePublicCache(PublicCacheData, cancellationToken);
}
[JSInvokable]
- public async Task LogFromJs(string message, string where)
+ public async Task LogFromJs(string message, string where) =>
+ await ProcessError(new(message), where);
+
+ async Task PollOnline(CancellationToken cancellationToken)
{
try
{
- await ProcessError(new(message), where);
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, $"{nameof(CascadingState)}.{nameof(LogFromJs)}");
- }
- }
-
- async Task UpdateIsOnline()
- {
- try
- {
- var latestOnlineState = await Status.IsOnline();
-
- //var latestOnlineState = Random.Shared.Next() % 2 == 0;
- if (latestOnlineState != IsOnline)
+ while (await onlineTimer.WaitForNextTickAsync(cancellationToken))
{
- IsOnline = latestOnlineState;
- StateHasChanged();
+ var isOnline = await Status.IsOnline(cancellationToken);
+ if (isOnline == IsOnline)
+ continue;
+
+ IsOnline = isOnline;
+ await InvokeAsync(StateHasChanged);
}
- // else
- // {
- // var pingIsOnline = await DataService.Ping();
- // if (pingIsOnline != IsOnline)
- // {
- // IsOnline = pingIsOnline;
- // StateHasChanged();
- // }
- // }
+ }
+ catch (OperationCanceledException)
+ {
}
catch (Exception ex)
{
- Logger.LogError(ex, $"{nameof(CascadingState)}.{nameof(UpdateIsOnline)}");
+ await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(PollOnline)}", cancellationToken);
}
}
- public async ValueTask ProcessError(Exception ex, string where)
+ /// Logs an unexpected failure and tells the user.
+ public async ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default)
{
- try
- {
- await DbStorage.AddLog(ex, where);
- Logger.LogError(ex, where);
- await Task.Run(() => {
- });
- }
- catch (Exception exception)
- {
- Logger.LogError(exception, $"{nameof(CascadingState)}.{nameof(ProcessError)}");
- }
+ await Logger.ProcessError(exception, where, cancellationToken);
+ Toast.AddException(exception);
}
- public async ValueTask ProcessWarning(string message, string where)
+ public async ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
{
- try
- {
- await DbStorage.AddLog(message, where);
- Logger.LogWarning("{where} - {message}", where, message);
- await Task.Run(() => {
- });
- }
- catch (Exception exception)
- {
- Logger.LogError(exception, $"{nameof(CascadingState)}.{nameof(ProcessWarning)}");
- }
+ await Logger.ProcessWarning(message, where, cancellationToken);
+ Toast.AddWarning(message);
}
-}
\ No newline at end of file
+
+ public async ValueTask DisposeAsync()
+ {
+ await disposeCancellation.CancelAsync();
+ disposeCancellation.Dispose();
+ onlineTimer?.Dispose();
+ cascadingStateReference?.Dispose();
+ }
+}
diff --git a/LayerComponents/LocalizableComponentBase.cs b/LayerComponents/LocalizableComponentBase.cs
index 95d211a..71be030 100644
--- a/LayerComponents/LocalizableComponentBase.cs
+++ b/LayerComponents/LocalizableComponentBase.cs
@@ -1,12 +1,109 @@
-using collAnon.Client.Services;
+using collAnon.Client.Services;
+
+using decePubClient.Services;
+
using Microsoft.AspNetCore.Components;
namespace decePubClient.LayerComponents
{
- public class LocalizableComponentBase : ComponentBase
+ ///
+ /// 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.
+ ///
+ public class LocalizableComponentBase : ComponentBase, IAsyncDisposable
{
+ [CascadingParameter] protected CascadingState CascadingState { get; set; }
[Inject] public CoalescingStringLocalizer Localizer { get; set; }
- protected List> AfterRenderAsyncJobs = new();
+ [Inject] protected NavigationManager Navigation { get; set; }
+ [Inject] protected ToastService Toast { get; set; }
+
+ /// Work to run once after the next render, e.g. JS interop that needs the rendered DOM.
+ protected List> AfterRenderAsyncJobs = [];
+
+ /// True until the component's first load completes; set it to false at the end of OnInitializedAsync.
protected bool IsLoading { get; set; } = true;
+
+ /// What every Disabled= binding starts from: loading, or no connection.
+ protected bool IsDefaultDisabled => IsLoading || CascadingState.IsOffline;
+
+ readonly List> 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();
+ }
+
+ /// Runs the job once, the next time the app is online (needs StartOnlinePolling).
+ protected void WhenOnline(Func job) => onOnlineJobs.Add(job);
+
+ /// Checks every interval (10 seconds by default) whether the app is back online to run the WhenOnline jobs.
+ 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);
+ }
}
}
diff --git a/Models/BoostingUser.cs b/Models/BoostingUser.cs
index 336ded2..6a0c834 100644
--- a/Models/BoostingUser.cs
+++ b/Models/BoostingUser.cs
@@ -2,10 +2,10 @@
{
public class BoostingUser
{
- public string UserId { get; set; } = "45f14fa8-c40f-4121-997c-ef2542196a50";
- public string UserName { get; set; } = "@loosy@letsrulethe.world";
- public string DisplayName { get; set; } = "loosy";
- public string PictureUrl { get; set; } = "https://letsrulethe.world/media/ddd91d51-dc12-4b82-86a2-2a28cc959183/L51ZAL4QEL07";
- public string ProfileUrl { get; set; } = "https://letsrulethe.world/users/loosy";
+ public string UserId { get; set; }
+ public string UserName { get; set; }
+ public string DisplayName { get; set; }
+ public string PictureUrl { get; set; }
+ public string ProfileUrl { get; set; }
}
}
diff --git a/Models/ClientLogs.cs b/Models/ClientLogs.cs
index 3e33306..e577914 100644
--- a/Models/ClientLogs.cs
+++ b/Models/ClientLogs.cs
@@ -7,21 +7,19 @@
public string WarningMessage { get; set; }
public ClientLogException Exception { get; set; }
public DateTime TimeStamp { get; set; } = DateTime.Now;
+ /// The entry as HTML for a log viewer: the warning text, or the exception's details.
public string ErrorMessage()
{
- if (Exception.InnerExceptionMessage != null)
- {
- return @$"Type : {Exception.Type ?? "N/A"} HelpLink : {Exception.HelpLink ?? "N/A"} HResult : {Exception.HResult ?? "N/A"}
-Source : {Exception.Source ?? "N/A"} StackTrace : {Exception.StackTrace ?? "N/A"} MemberName : {Exception.TargetSiteName ?? "N/A"}
-Message : {Exception.Message ?? "N/A"}
-InnerExceptionMessage :{Exception.InnerExceptionMessage ?? "N/A"}";
- }
- else
- {
- return @$"Type : {Exception.Type ?? "N/A"} HelpLink : {Exception.HelpLink ?? "N/A"} HResult : {Exception.HResult ?? "N/A"}
+ if (Exception is null)
+ return WarningMessage;
+
+ var details = @$"Type : {Exception.Type ?? "N/A"} HelpLink : {Exception.HelpLink ?? "N/A"} HResult : {Exception.HResult ?? "N/A"}
Source : {Exception.Source ?? "N/A"} StackTrace : {Exception.StackTrace ?? "N/A"} MemberName : {Exception.TargetSiteName ?? "N/A"}
Message : {Exception.Message ?? "N/A"}";
- }
+ if (Exception.InnerExceptionMessage is null)
+ return details;
+
+ return $"{details} \nInnerExceptionMessage :{Exception.InnerExceptionMessage}";
}
}
diff --git a/Models/FailureCodes.cs b/Models/FailureCodes.cs
new file mode 100644
index 0000000..aec4e2e
--- /dev/null
+++ b/Models/FailureCodes.cs
@@ -0,0 +1,20 @@
+namespace decePubClient.Models;
+
+///
+/// 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.
+///
+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;
+}
diff --git a/Models/Media.cs b/Models/Media.cs
index e3dfd19..debe030 100644
--- a/Models/Media.cs
+++ b/Models/Media.cs
@@ -6,6 +6,6 @@
public string Url { get; set; }
public string ContentType { get; set; }
public string AltText { get; set; }
- public byte[] Blob { get; set; } //TODO TEMPORARY
+ public byte[] Blob { get; set; } = []; //TODO TEMPORARY
}
}
diff --git a/Models/Message.cs b/Models/Message.cs
index 63fb90f..1c53bce 100644
--- a/Models/Message.cs
+++ b/Models/Message.cs
@@ -16,7 +16,7 @@ namespace decePubClient.Models
public int BoostsCounter { get; set; } = 0;
public bool IsBoostedByCurrentUser { get; set; } = false;
public BoostingUser BoostingUser { get; set; }
- public List Medias { get; set; } = new();
+ public List Medias { get; set; } = [];
public DateTime CreatedAt { get; set; }
[Bindable(false), JsonIgnore]
diff --git a/Models/MessageForm.cs b/Models/MessageForm.cs
index f71a11d..9309bc9 100644
--- a/Models/MessageForm.cs
+++ b/Models/MessageForm.cs
@@ -19,7 +19,7 @@ public class MessageForm
public string Title { get; set; }
[StringLength(5_000, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
public string Content { get; set; }
- public List Media { get; set; } = new();
+ public List Media { get; set; } = [];
[JsonIgnore, Bindable(false)]
public MediaType MediaType { get; set; } = MediaType.Images;
diff --git a/Models/MessageUser.cs b/Models/MessageUser.cs
index 49cc838..1b03603 100644
--- a/Models/MessageUser.cs
+++ b/Models/MessageUser.cs
@@ -2,11 +2,11 @@
{
public class MessageUser
{
- public string UserId { get; set; } = "45f14fa8-c40f-4121-997c-ef2542196a50";
- public string UserName { get; set; } = "@loosy@letsrulethe.world";
- public string DisplayName { get; set; } = "loosy";
- public string PictureUrl { get; set; } = "https://letsrulethe.world/media/ddd91d51-dc12-4b82-86a2-2a28cc959183/L51ZAL4QEL07";
- public string BackgroundUrl { get; set; } = "https://letsrulethe.world/media/9a3cc6c4-1018-4f41-86c7-f3e63e362bf4/EXM8RUK10B0Z";
- public string ProfileUrl { get; set; } = "https://letsrulethe.world/users/loosy";
+ public string UserId { get; set; }
+ public string UserName { get; set; }
+ public string DisplayName { get; set; }
+ public string PictureUrl { get; set; }
+ public string BackgroundUrl { get; set; }
+ public string ProfileUrl { get; set; }
}
}
diff --git a/Models/PublicCacheData.cs b/Models/PublicCacheData.cs
index 34c1401..a35108c 100644
--- a/Models/PublicCacheData.cs
+++ b/Models/PublicCacheData.cs
@@ -6,12 +6,12 @@ namespace decePubClient.Models
{
public class PublicCacheData
{
- public IReadOnlyList Languages { get; set; } = new List();
- public List QrCodeDevices { get; set; } = new();
+ public IReadOnlyList Languages { get; set; } = [];
+ public List QrCodeDevices { get; set; } = [];
public string LastPage { get; set; }
public string CurrentSubPage { get; set; }
public PageSettings PageSettings { get; set; } = new();
public bool ShowLogs { get; set; } = false;
- public IReadOnlyDictionary Policies { get; set; } = new ReadOnlyDictionary(new Dictionary());
+ public IReadOnlyDictionary Policies { get; set; } = ReadOnlyDictionary.Empty;
}
}
diff --git a/Models/UploadMedia.cs b/Models/UploadMedia.cs
index 56cdfe9..818de50 100644
--- a/Models/UploadMedia.cs
+++ b/Models/UploadMedia.cs
@@ -8,7 +8,7 @@ public class UploadMedia
public string ContentType { get; set; }
public string FileName { get; set; }
public string AltText { get; set; }
- public byte[] Blob { get; set; }
+ public byte[] Blob { get; set; } = [];
[JsonIgnore, Bindable(false)]
public string Base64Preview { get; set; }
diff --git a/Models/User.cs b/Models/User.cs
index 122c0d5..3fc6084 100644
--- a/Models/User.cs
+++ b/Models/User.cs
@@ -2,15 +2,34 @@
{
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 List Claims { get; set; } = new();
+ public List Claims { get; set; } = [];
- public string UserName { get; set; } = "@loosy@letsrulethe.world";
- public string DisplayName { get; set; } = "loosy";
- public string PictureUrl { get; set; } = "https://letsrulethe.world/media/ddd91d51-dc12-4b82-86a2-2a28cc959183/L51ZAL4QEL07";
- public string BackgroundUrl { get; set; } = "https://letsrulethe.world/media/9a3cc6c4-1018-4f41-86c7-f3e63e362bf4/EXM8RUK10B0Z";
- public string ProfileUrl { get; set; } = "https://letsrulethe.world/users/loosy";
+ public string UserName { get; set; }
+ public string DisplayName { get; set; }
+ public string PictureUrl { get; set; }
+ public string BackgroundUrl { get; set; }
+ 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
diff --git a/Models/ValidationErrors.cs b/Models/ValidationErrors.cs
new file mode 100644
index 0000000..a357b32
--- /dev/null
+++ b/Models/ValidationErrors.cs
@@ -0,0 +1,11 @@
+namespace decePubClient.Models;
+
+/// ASP.NET Core's validation ProblemDetails, as the server returns it for an invalid request body.
+public class ValidationErrors
+{
+ public string Type { get; set; }
+ public string Title { get; set; }
+ public int Status { get; set; }
+ public Dictionary Errors { get; set; } = [];
+ public string TraceId { get; set; }
+}
diff --git a/Pages/Administration.razor b/Pages/Administration.razor
index 2323425..911f17b 100644
--- a/Pages/Administration.razor
+++ b/Pages/Administration.razor
@@ -177,5 +177,4 @@
@code {
- [CascadingParameter] CascadingState CascadingState { get; set; }
}
diff --git a/Pages/ExpandMessage.razor b/Pages/ExpandMessage.razor
index 346d215..41bb008 100644
--- a/Pages/ExpandMessage.razor
+++ b/Pages/ExpandMessage.razor
@@ -14,29 +14,37 @@
@Localizer["loading"]
}
-
- @foreach (var message in Ancestors)
+ else if (IsNotFound)
{
-
+
+ @Localizer["This message doesn't exist."]
+
}
-
-
-
- @foreach (var message in Descendants)
+ else
{
-
+ }
+
+
+
+ @foreach (var message in Descendants)
+ {
+
+ }
}
@@ -45,146 +53,137 @@
@code {
- [CascadingParameter] CascadingState CascadingState { get; set; }
- [Inject] NavigationManager Navigation { get; set; }
[Inject] IStorage DbStorage { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
[Inject] IBlazorDownloadFileService BlazorDownloadFileService { get; set; }
[Inject] MessagesService MessagesService { get; set; }
[Parameter] public string MessageId { get; set; }
- List Ancestors { get; set; } = new();
+ List Ancestors { get; set; } = [];
Message CurrentMessage { get; set; } = new();
- List Descendants { get; set; } = new();
-
+ List Descendants { get; set; } = [];
+ bool IsNotFound { get; set; }
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;
}
- var filters = await LocalStorage.GetItemAsync(nameof(ActionBarFilter));
- if (filters == default)
- await LocalStorage.SetItemAsync(nameof(ActionBarFilter), new ActionBarFilter());
- else
- TimeSortingType = filters.TimeSortingType;
+ IsLoading = true;
+ var filters = await LocalStorage.GetItemAsync(nameof(ActionBarFilter)) ?? new();
+ TimeSortingType = filters.TimeSortingType;
- CurrentMessage = await DbStorage.GetMessage(MessageId);
-
- Ancestors = new List
+ if (CascadingState.IsOffline)
{
- new()
- {
- RootMessageId = default,
- MessageId = "51C698E3-7C28-4C90-9212-48D5C81DE089",
- User = Faker.GetRandomUser(),
- MessageType = (MessageType)Random.Shared.Next(0, 4),
- Title = CurrentMessage?.Title,
- Content = "esempio di messaggio precedente a quello espanso
",
- 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,
- }
- };
+ WhenOnline(LoadData);
+ StartOnlinePolling();
+ IsLoading = false;
+ return;
+ }
- Descendants = new List
- {
- new()
- {
- RootMessageId = default,
- MessageId = "0569A1DF-46FC-4485-9F11-F8CDFB794404",
- User = Faker.GetRandomUser(),
- MessageType = (MessageType)Random.Shared.Next(0, 4),
- Title = CurrentMessage?.Title,
- Content = "esempio di messaggio successivo a quello espanso
",
- 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,
- }
- };
+ await LoadData();
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)
{
- var replyMessage = await MessagesService.SubmitMessage(messageForm);
- Descendants.Add(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
- },
- });
+ var result = await MessagesService.SubmitMessage(messageForm, CascadingState.User);
+ if (result.IsInvalid)
+ {
+ Toast.AddResponseError(result);
+ return;
+ }
+
+ Descendants.Add(result.Data as Message);
}
- async ValueTask OnMessageBoost(Message message)
+ async Task OnMessageBoost(Message message)
{
- if (message.IsBoostedByCurrentUser) return;
- var boostedMessage = await MessagesService.BoostUnboostMessage(message);
- message.BoostsCounter += 1;
+ if (message.IsBoostedByCurrentUser)
+ return;
- //Add to the home page in the background
+ var result = await MessagesService.BoostUnboostMessage(message);
+ if (result.IsInvalid)
+ Toast.AddResponseError(result);
}
- async ValueTask OnMessageFavourite(Message message)
+ async Task OnMessageFavourite(Message message)
{
- var favouriteMessage = await MessagesService.FavouriteUnfavouriteMessage(message);
- message.IsFavourite = !message.IsFavourite;
+ var result = await MessagesService.FavouriteUnfavouriteMessage(message);
+ if (result.IsInvalid)
+ Toast.AddResponseError(result);
}
- async ValueTask OnMessageDelete(Message message)
+ async Task OnMessageDelete(Message message)
{
- var deleteResult = await MessagesService.DeleteMessage(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 ValueTask OnMessageMediaDownload(Media media)
+ async Task OnMessageMediaDownload(Media media)
{
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;
+ }
+
+ Toast.AddResponseSuccess(Localizer["{0} blocked.", messageUser.DisplayName]);
}
- async ValueTask OnUserDirectMessage(Message message)
+ async Task OnUserDirectMessage(Message message)
{
- var directMessage = await MessagesService.ReplyMessage(message, MessageType.Direct);
+ var result = await MessagesService.ReplyMessage(message, MessageType.Direct);
+ if (result.IsInvalid)
+ Toast.AddResponseError(result);
}
- async ValueTask OnUserSilence(MessageUser messageUser)
+ async Task OnUserSilence(MessageUser messageUser)
{
- var silenceResult = await MessagesService.SilenceUserFromMessage(messageUser);
+ var result = await MessagesService.SilenceUserFromMessage(messageUser);
+ if (result.IsInvalid)
+ {
+ Toast.AddResponseError(result);
+ return;
+ }
+
+ Toast.AddResponseSuccess(Localizer["{0} muted.", messageUser.DisplayName]);
}
-}
\ No newline at end of file
+}
diff --git a/Pages/Index.razor b/Pages/Index.razor
index eb298c8..c45adac 100644
--- a/Pages/Index.razor
+++ b/Pages/Index.razor
@@ -27,7 +27,7 @@
{
}
@@ -58,55 +58,39 @@
@code {
- [CascadingParameter] Task AuthState { get; set; }
- [CascadingParameter] CascadingState CascadingState { get; set; }
[Inject] IBlazorDownloadFileService BlazorDownloadFileService { get; set; }
[Inject] IStorage Storage { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
- [Inject] HttpClient Http { get; set; }
[Inject] MessagesService MessagesService { get; set; }
TimelineType TimelineType { get; set; } = TimelineType.Home;
TimeSortingType TimeSortingType { get; set; } = TimeSortingType.Ascending;
-
- IReadOnlyList userClaim
- {
- get
- {
- var auth = AuthState.Result;
- if (auth.User.Identity?.IsAuthenticated ?? false)
- return auth.User.Claims.ToList();
- return new List();
- }
- }
-
- List IncomingMessages { get; set; } = new();
- List Messages { get; set; } = new();
- string Response { get; set; }
+ List Messages { get; set; } = [];
protected override async Task OnInitializedAsync()
{
- var filters = await LocalStorage.GetItemAsync(nameof(ActionBarFilter));
- if (filters == default)
- await LocalStorage.SetItemAsync(nameof(ActionBarFilter), new ActionBarFilter());
- else
+ var filters = await LocalStorage.GetItemAsync(nameof(ActionBarFilter)) ?? new();
+ TimelineType = filters.TimelineType;
+ TimeSortingType = filters.TimeSortingType;
+
+ if (CascadingState.IsOffline)
{
- TimelineType = filters.TimelineType;
- TimeSortingType = filters.TimeSortingType;
+ WhenOnline(LoadData);
+ StartOnlinePolling();
+ IsLoading = false;
+ return;
}
- Messages = await Storage.GetMessages();
- //try
- //{
- // var response = await Http.GetAsync("api/test");
- // Response = await response.Content.ReadAsStringAsync();
- //}
- //catch
- //{
- //}
+ await LoadData();
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)
{
TimelineType = timelineType;
@@ -117,143 +101,105 @@
TimeSortingType = timeSortingType;
}
+ // Covers replies too: the form carries the RootMessageId.
async Task OnMessageSubmit(MessageForm messageForm)
{
- await Task.Run(() =>
+ var result = await MessagesService.SubmitMessage(messageForm, CascadingState.User);
+ if (result.IsInvalid)
{
- });
- var submitMessage = await MessagesService.SubmitMessage(messageForm);
- 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)
- {
- 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)
- {
- var boostedMessageTemp = await MessagesService.BoostUnboostMessage(message);
- message.BoostsCounter = message.IsBoostedByCurrentUser ? message.BoostsCounter - 1 : message.BoostsCounter + 1;
- message.IsBoostedByCurrentUser = !message.IsBoostedByCurrentUser;
- if (message.IsBoostedByCurrentUser)
- {
- var boostedMessage = new Message
- {
- BoostingUser = new()
- {
- UserId = CascadingState.User.Id,
- DisplayName = CascadingState.User.DisplayName,
- PictureUrl = CascadingState.User.PictureUrl,
- ProfileUrl = CascadingState.User.ProfileUrl,
- UserName = CascadingState.User.UserName
- },
- BoostsCounter = message.BoostsCounter,
- Content = message.Content,
- CreatedAt = message.CreatedAt,
- IsBoostedByCurrentUser = true,
- IsFavourite = message.IsFavourite,
- Medias = message.Medias,
- MessageId = message.MessageId,
- MessageType = message.MessageType,
- RootMessageId = message.RootMessageId,
- Title = message.Title,
- User = message.User
- };
- Messages.Insert(0, boostedMessage);
+ Toast.AddResponseError(result);
+ return;
}
- else
+
+ Messages.Insert(0, result.Data as Message);
+ }
+
+ async Task OnMessageBoost(Message message)
+ {
+ var result = await MessagesService.BoostUnboostMessage(message);
+ if (result.IsInvalid)
{
- Messages.RemoveAll(m => m.MessageId == message.MessageId && m.BoostingUser != null && m.BoostingUser.UserId == CascadingState.User.Id);
+ Toast.AddResponseError(result);
+ return;
}
+
+ if (!message.IsBoostedByCurrentUser)
+ {
+ 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()
+ {
+ BoostingUser = CascadingState.User.ToBoostingUser(),
+ BoostsCounter = message.BoostsCounter,
+ Content = message.Content,
+ CreatedAt = message.CreatedAt,
+ IsBoostedByCurrentUser = true,
+ IsFavourite = message.IsFavourite,
+ Medias = message.Medias,
+ MessageId = message.MessageId,
+ MessageType = message.MessageType,
+ RootMessageId = message.RootMessageId,
+ Title = message.Title,
+ User = message.User
+ });
}
- async ValueTask OnMessageFavourite(Message message)
+ async Task OnMessageFavourite(Message message)
{
- message.IsFavourite = !message.IsFavourite;
- var favouriteMessage = await MessagesService.FavouriteUnfavouriteMessage(message);
+ var result = await MessagesService.FavouriteUnfavouriteMessage(message);
+ if (result.IsInvalid)
+ Toast.AddResponseError(result);
}
- async ValueTask OnMessageDelete(Message message)
+ async Task OnMessageDelete(Message message)
{
+ var result = await MessagesService.DeleteMessage(message);
+ if (result.IsInvalid)
+ {
+ Toast.AddResponseError(result);
+ return;
+ }
+
Messages.Remove(message);
- var deleteResult = await MessagesService.DeleteMessage(message);
+ Toast.AddResponseSuccess(Localizer["Message deleted."]);
}
- async ValueTask OnMessageMediaDownload(Media media)
+ async Task OnMessageMediaDownload(Media media)
{
- await Task.Run(() =>
- {
- });
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;
+ }
+
+ Toast.AddResponseSuccess(Localizer["{0} blocked.", messageUser.DisplayName]);
}
- async ValueTask OnUserDirectMessage(Message message)
+ async Task OnUserDirectMessage(Message message)
{
- var directMessage = await MessagesService.ReplyMessage(message, MessageType.Direct);
+ var result = await MessagesService.ReplyMessage(message, MessageType.Direct);
+ if (result.IsInvalid)
+ Toast.AddResponseError(result);
}
- async ValueTask OnUserSilence(MessageUser messageUser)
+ async Task OnUserSilence(MessageUser messageUser)
{
- var silenceResult = await MessagesService.SilenceUserFromMessage(messageUser);
- }
+ var result = await MessagesService.SilenceUserFromMessage(messageUser);
+ if (result.IsInvalid)
+ {
+ Toast.AddResponseError(result);
+ return;
+ }
-}
\ No newline at end of file
+ Toast.AddResponseSuccess(Localizer["{0} muted.", messageUser.DisplayName]);
+ }
+}
diff --git a/Pages/Login.razor b/Pages/Login.razor
index 734b4d3..a43f8c4 100644
--- a/Pages/Login.razor
+++ b/Pages/Login.razor
@@ -11,6 +11,5 @@
@code {
- [CascadingParameter] CascadingState CascadingState { get; set; }
}
diff --git a/Pages/Logout.razor b/Pages/Logout.razor
index d83cb86..1dee39f 100644
--- a/Pages/Logout.razor
+++ b/Pages/Logout.razor
@@ -11,9 +11,7 @@
@code {
- [CascadingParameter] CascadingState CascadingState { 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
// database. The template's authentication/logout route can't do it: RemoteAuthenticatorView needs the OIDC
diff --git a/Pages/Settings.razor b/Pages/Settings.razor
index e898458..83444ea 100644
--- a/Pages/Settings.razor
+++ b/Pages/Settings.razor
@@ -15,23 +15,32 @@
@* Every theme setting; NavMenu's toggle and slider are shortcuts to the dark and hue ones. *@
-
+
+
+ @Localizer["Language"]
+ @* Language names stay in their own language. Changing it reloads the app with the new culture. *@
+
+ English
+ Italiano
+
+
@Localizer["Light theme colour"]
+ Value="CascadingState.PublicCacheData.PageSettings.LightThemeIndexColour" ValueChanged="v => Update(s => s.SetHue(false, v))"
+ ValueExpression="() => CascadingState.PublicCacheData.PageSettings.LightThemeIndexColour" />
@Localizer["Dark theme colour"]
+ Value="CascadingState.PublicCacheData.PageSettings.DarkThemeIndexColour" ValueChanged="v => Update(s => s.SetHue(true, v))"
+ ValueExpression="() => CascadingState.PublicCacheData.PageSettings.DarkThemeIndexColour" />
@@ -87,11 +96,15 @@
@code {
- [CascadingParameter] CascadingState CascadingState { get; set; }
- PageSettings Theme => CascadingState.PublicCacheData?.PageSettings ?? new();
bool IsDark => CascadingState.Status.IsDarkTheme();
- bool IsGray => Theme.IsGray();
+ bool IsGray => CascadingState.PublicCacheData.PageSettings.IsGray();
Task Update(Action change) => CascadingState.UpdatePageSettings(change).AsTask();
+
+ async Task ChangeLanguage(string languageCode)
+ {
+ await Update(settings => settings.CurrentLanguageCode = languageCode);
+ Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
+ }
}
diff --git a/Program.cs b/Program.cs
index 348c28e..f871b1d 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,9 +1,10 @@
-using decePubClient;
+using decePubClient;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using System.Net.Http.Headers;
using System.Text.Json;
using Blazored.LocalStorage;
using decePubClient.Extensions;
+using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Services;
using Toolbelt.Blazor.Extensions.DependencyInjection;
@@ -39,12 +40,15 @@ builder.Services.AddOptions()
config.JsonSerializerOptions.IgnoreReadOnlyProperties = true;
config.JsonSerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip;
config.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ config.JsonSerializerOptions.TypeInfoResolver = SUtility.DefaultSerializer.TypeInfoResolver;
})
.AddScoped()
.AddScoped()
.AddScoped(provider => provider.GetRequiredService())
+ .AddScoped()
+ .AddScoped()
.AddSingleton(typeof(CoalescingStringLocalizer))
- //.AddTransient()
+ .AddTransient()
.AddHeadElementHelper(options => options.DisableClientScriptAutoInjection = true)
.AddLocalization()
.AddTransient()
diff --git a/Services/AppStatusService.cs b/Services/AppStatusService.cs
index 83cad2a..cfc9586 100644
--- a/Services/AppStatusService.cs
+++ b/Services/AppStatusService.cs
@@ -4,20 +4,12 @@ using Microsoft.JSInterop;
namespace decePubClient.Services;
-public class AppStatusService
+public class AppStatusService(IJSRuntime jsRuntime)
{
- readonly IJSRuntime JsRuntime;
- readonly IJSInProcessRuntime JsSyncRuntime;
+ readonly IJSInProcessRuntime JsSyncRuntime = (IJSInProcessRuntime)jsRuntime;
- public AppStatusService(IJSRuntime JSRuntime)
- {
- JsRuntime = JSRuntime;
- JsSyncRuntime = (IJSInProcessRuntime)JSRuntime;
- }
-
- public async ValueTask IsOnline() => await JsSyncRuntime.InvokeAsync("isOnline");
-
- public bool InputCaptureSupported() => JsSyncRuntime.Invoke("inputCaptureSupported");
+ public async ValueTask IsOnline(CancellationToken cancellationToken = default) =>
+ await jsRuntime.InvokeAsync("isOnline", cancellationToken);
public bool IsServiceWorkerAvailable() => JsSyncRuntime.Invoke("isServiceWorkerAvailable");
@@ -31,15 +23,11 @@ public class AppStatusService
public void ClearAppBadge() => JsSyncRuntime.InvokeVoid("clearAppBadge");
- public async ValueTask ClearCache()
- {
- await JsRuntime.InvokeVoidAsync("clearCache");
- }
+ public async ValueTask ClearCache(CancellationToken cancellationToken = default) =>
+ await jsRuntime.InvokeVoidAsync("clearCache", cancellationToken);
- public async ValueTask ClearLocalStorage()
- {
- await JsRuntime.InvokeVoidAsync("clearLocalStorage");
- }
+ public async ValueTask ClearLocalStorage(CancellationToken cancellationToken = default) =>
+ await jsRuntime.InvokeVoidAsync("clearLocalStorage", cancellationToken);
public void ReloadPage()
{
@@ -55,7 +43,9 @@ public class AppStatusService
public bool CanShare() => JsSyncRuntime.Invoke("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);
}
\ No newline at end of file
diff --git a/Services/CoalescingStringLocalizer.cs b/Services/CoalescingStringLocalizer.cs
index 1370585..6c54505 100644
--- a/Services/CoalescingStringLocalizer.cs
+++ b/Services/CoalescingStringLocalizer.cs
@@ -6,50 +6,59 @@ using PrivaPub.ClientModels.Resources;
namespace collAnon.Client.Services
{
- public sealed class CoalescingStringLocalizer
+ ///
+ /// 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.
+ ///
+ public sealed class CoalescingStringLocalizer(IStringLocalizer baseLocalizer, IStringLocalizer fieldsLocalizer,
+ IStringLocalizer errorsLocalizer)
{
- public readonly IStringLocalizer _pLocalizer;
- readonly IStringLocalizer _fLocalizer;
- readonly IStringLocalizer _eLocalizer;
+ public IStringLocalizer Base => baseLocalizer;
+ public IStringLocalizer Fields => fieldsLocalizer;
+ public IStringLocalizer Errors => errorsLocalizer;
- public CoalescingStringLocalizer(
- IStringLocalizer pLocalizer,
- IStringLocalizer fLocalizer,
- IStringLocalizer eLocalizer) =>
- (_pLocalizer, _fLocalizer, _eLocalizer) =
- (pLocalizer, fLocalizer, eLocalizer);
-
- internal LocalizedString this[string name]
+ public LocalizedString this[string name]
{
get
{
- if (_pLocalizer[name].ResourceNotFound)
- if (_fLocalizer[name].ResourceNotFound)
- if (_eLocalizer[name].ResourceNotFound)
- return new(name, name, false);
- else
- return _eLocalizer[name];
- else
- return _fLocalizer[name];
- else
- return _pLocalizer[name];
+ try
+ {
+ if (!baseLocalizer[name].ResourceNotFound)
+ return baseLocalizer[name];
+ if (!fieldsLocalizer[name].ResourceNotFound)
+ return fieldsLocalizer[name];
+ if (!errorsLocalizer[name].ResourceNotFound)
+ return errorsLocalizer[name];
+ return new(name, name, true);
+ }
+ 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
{
- if (_pLocalizer[name].ResourceNotFound)
- if (_fLocalizer[name].ResourceNotFound)
- if (_eLocalizer[name].ResourceNotFound)
- return new(name, name, false);
- else
- return _eLocalizer[name, arguments];
- else
- return _fLocalizer[name, arguments];
- else
- return _pLocalizer[name, arguments];
+ try
+ {
+ if (!baseLocalizer[name].ResourceNotFound)
+ return baseLocalizer[name, arguments];
+ if (!fieldsLocalizer[name].ResourceNotFound)
+ return fieldsLocalizer[name, arguments];
+ if (!errorsLocalizer[name].ResourceNotFound)
+ return errorsLocalizer[name, arguments];
+ return new(name, string.Format(name, arguments), true);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine(ex.ToString());
+ return new(name, name, true);
+ }
}
}
}
diff --git a/Services/IHttpService.cs b/Services/IHttpService.cs
index 0c90aee..fc99255 100644
--- a/Services/IHttpService.cs
+++ b/Services/IHttpService.cs
@@ -1,196 +1,217 @@
-using Blazored.LocalStorage;
-using System.Globalization;
-using System.Net.Http.Json;
using System.Net;
+using System.Net.Http.Json;
+
+using collAnon.Client.Services;
+
+using decePubClient.Helpers;
using decePubClient.Models;
namespace decePubClient.Services
{
+ ///
+ /// 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.
+ ///
public interface IHttpService
{
- Task Get(string uri, object payload = default, string[] queryParams = default);
+ Task Get(string uri, string[] queryParams = default, bool retryOnError = false, CancellationToken cancellationToken = default);
- Task GetAnon(string uri, object payload = default, string[] queryParams = default);
+ Task GetAnon(string uri, string[] queryParams = default, bool retryOnError = false, CancellationToken cancellationToken = default);
- Task Post(string uri, object payload = default);
+ Task GetTotallyAnon(string uri, string[] queryParams = default, bool retryOnError = false,
+ CancellationToken cancellationToken = default);
- Task PostAnon(string uri, object payload = default);
+ Task Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
- Task Delete(string uri, string[] queryParams = default, object payload = default);
+ Task PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
+
+ Task PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
+ CancellationToken cancellationToken = default);
+
+ Task Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
+ CancellationToken cancellationToken = default);
+
+ Task 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;
- readonly TokenAuthStateProvider AuthStateProvider;
- readonly ILogger Logger;
- readonly ILocalStorageService Storage;
- readonly IStorage DbStorage;
+ static readonly int[] RetryRampUpDelaysMs = [1_000, 2_000, 4_000, 8_000, 16_000, 29_000];
+ const int RetryIndefiniteDelayMs = 60_000;
+ static readonly TimeSpan RetryDeadline = TimeSpan.FromMinutes(10);
+ static readonly int[] RetryableStatusCodes = [408, 429, 502, 503, 504];
- public HttpService(
- IHttpClientFactory httpClientFactory,
- TokenAuthStateProvider authStateProvider,
- ILogger logger,
- ILocalStorageService storage,
- IStorage dbStorage)
- {
- HttpClientFactory = httpClientFactory;
- AuthStateProvider = authStateProvider;
- Logger = logger;
- Storage = storage;
- DbStorage = dbStorage;
- }
+ readonly HttpClient httpClient = httpClientFactory.CreateClient("default");
- public async Task Get(string uri, object payload = default, string[] queryParams = default)
- {
- try
- {
- uri = $"{CultureInfo.DefaultThreadCurrentCulture.TwoLetterISOLanguageName}/{uri}";
- 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 SendRequest(request);
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, $"{nameof(Get)}:{uri}");
- await DbStorage.AddLog(ex, $"{nameof(Get)}:{uri}");
- return new(HttpStatusCode.ServiceUnavailable);
- }
- }
+ public Task 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 async Task Post(string uri, object payload = default)
+ public Task 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 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 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 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 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 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 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 Send(string caller, Func requestFactory,
+ Func> send, bool retryOnError, CancellationToken cancellationToken)
{
- try
+ var deadline = DateTime.UtcNow + RetryDeadline;
+ var attempt = 0;
+ while (true)
{
- uri = $"{CultureInfo.DefaultThreadCurrentCulture.TwoLetterISOLanguageName}/{uri}";
- var request = new HttpRequestMessage(HttpMethod.Post, uri)
+ // 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
{
- Content = JsonContent.Create(payload)
- };
- return await SendRequest(request);
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, $"{nameof(Post)}:{uri}");
- await DbStorage.AddLog(ex, $"{nameof(Post)}:{uri}");
- return new(HttpStatusCode.ServiceUnavailable);
- }
- }
+ var response = await send(request, cancellationToken);
+ var status = (int)response.StatusCode;
+ if (!retryOnError || !RetryableStatusCodes.Contains(status))
+ return status > 499 ? ServerError(response) : response;
- public async Task GetAnon(string uri, object payload = default, string[] queryParams = default)
- {
- try
- {
- uri = $"{CultureInfo.DefaultThreadCurrentCulture.TwoLetterISOLanguageName}/{uri}";
- 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 PostAnon(string uri, object payload = default)
- {
- try
- {
- uri = $"{CultureInfo.DefaultThreadCurrentCulture.TwoLetterISOLanguageName}/{uri}";
- var request = new HttpRequestMessage(HttpMethod.Post, uri)
+ failure = $"HTTP {status}";
+ response.Dispose();
+ }
+ catch (Exception) when (cancellationToken.IsCancellationRequested)
{
- Content = JsonContent.Create(payload)
- };
- return await SendAnonRequest(request);
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, $"{nameof(PostAnon)}:{uri}");
- await DbStorage.AddLog(ex, $"{nameof(PostAnon)}:{uri}");
- return new(HttpStatusCode.ServiceUnavailable);
- }
- }
-
- public async Task Delete(string uri, string[] queryParams = default, object payload = default)
- {
- try
- {
- uri = $"{CultureInfo.DefaultThreadCurrentCulture.TwoLetterISOLanguageName}/{uri}";
- var request = default(HttpRequestMessage);
- 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 SendRequest(HttpRequestMessage request)
- {
- try
- {
- var authData = await Storage.GetItemAsync(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)
+ return SUtility.DefaultTaskCancelled();
+ }
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
- 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();
+ if (!retryOnError)
+ {
+ await logger.ProcessError(ex, where, cancellationToken);
+ return await DescribeUnreachable(ex, 1, cancellationToken);
+ }
+
+ failure = ex.Message;
+ transportException = ex;
+ }
+ catch (Exception ex)
+ {
+ await logger.ProcessError(ex, where, cancellationToken);
+ return SUtility.ClassifiedFailure(FailureCodes.ClientError,
+ localizer["The request could not be prepared. Reload the page and try again."], HttpStatusCode.ServiceUnavailable);
}
- return response;
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, $"{nameof(SendRequest)}:{request.RequestUri?.OriginalString}");
- await DbStorage.AddLog(ex, $"{nameof(SendRequest)}:{request.RequestUri?.OriginalString}");
- return new(HttpStatusCode.ServiceUnavailable);
+ 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
+ {
+ await Task.Delay(delay, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return SUtility.DefaultTaskCancelled();
+ }
+
+ attempt++;
}
}
- async Task SendAnonRequest(HttpRequestMessage request)
+ HttpResponseMessage ServerError(HttpResponseMessage response)
{
- try
+ var status = response.StatusCode;
+ response.Dispose();
+ return SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server reported an internal error (HTTP {0}).", (int)status], status);
+ }
+
+ /// Why a request got no answer: the device is offline, it timed out, retries ran out, or the server is unreachable.
+ async Task DescribeUnreachable(Exception exception, int attempts, CancellationToken cancellationToken)
+ {
+ if (!await statusService.IsOnline(cancellationToken))
+ return SUtility.ClassifiedFailure(FailureCodes.DeviceOffline,
+ localizer["This device is offline. Check the connection and try again."], HttpStatusCode.ServiceUnavailable);
+
+ if (exception is TaskCanceledException { InnerException: TimeoutException })
+ return SUtility.ClassifiedFailure(FailureCodes.Timeout,
+ localizer["The server took too long to answer. Try again later."], HttpStatusCode.ServiceUnavailable);
+
+ if (attempts > 1)
+ return SUtility.ClassifiedFailure(FailureCodes.RetriesExhausted,
+ localizer["The server is still not answering after {0} attempts. Try again later.", attempts], HttpStatusCode.ServiceUnavailable);
+
+ return SUtility.ClassifiedFailure(FailureCodes.ApiUnreachable,
+ localizer["The server can't be reached. Try again later."], HttpStatusCode.ServiceUnavailable);
+ }
+
+ async Task SendAuthRequest(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var token = await authStateProvider.GetToken(cancellationToken);
+ if (token is null)
{
- var authData = await Storage.GetItemAsync(nameof(AuthData));
- if (!string.IsNullOrEmpty(authData?.Token))
- request.Headers.Authorization = new("Bearer", authData.Token);
-
- var response = await HttpClientFactory.CreateClient().SendAsync(request);
+ logger.Process($"NO_TOKEN:{request.RequestUri?.OriginalString}", nameof(SendAuthRequest), LogLevel.Warning);
+ return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
+ }
+ request.Headers.Authorization = new("Bearer", token);
+ var response = await httpClient.SendAsync(request, cancellationToken);
+ if (response.StatusCode is not HttpStatusCode.Unauthorized)
return response;
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, $"{nameof(SendAnonRequest)}:{request.RequestUri?.OriginalString}");
- await DbStorage.AddLog(ex, $"{nameof(SendAnonRequest)}:{request.RequestUri?.OriginalString}");
- return new(HttpStatusCode.ServiceUnavailable);
- }
+
+ response.Dispose();
+ await authStateProvider.LogoutAsync(cancellationToken: cancellationToken);
+ return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
+ }
+
+ async Task SendAuthIfAvailableRequest(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var token = await authStateProvider.GetToken(cancellationToken);
+ if (token is not null)
+ request.Headers.Authorization = new("Bearer", token);
+ return await httpClient.SendAsync(request, cancellationToken);
+ }
+
+ static HttpRequestMessage BuildRequest(HttpMethod method, string uri, object payload = default, string[] queryParams = default,
+ bool forceContent = false)
+ {
+ var target = queryParams is { Length: > 0 } ? $"{uri}?{string.Join('&', queryParams)}" : uri;
+ var request = new HttpRequestMessage(method, target);
+ if (payload is not null || forceContent)
+ request.Content = JsonContent.Create(payload, payload?.GetType() ?? typeof(object), options: SUtility.DefaultSerializer);
+ return request;
}
}
}
diff --git a/Services/ILoggingService.cs b/Services/ILoggingService.cs
new file mode 100644
index 0000000..c0dcac6
--- /dev/null
+++ b/Services/ILoggingService.cs
@@ -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);
+}
+
+///
+/// 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.
+///
+public class LoggingService(IStorage storage, ILogger 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)}");
+ }
+ }
+}
diff --git a/Services/IStorage.cs b/Services/IStorage.cs
index fb45a29..f509e26 100644
--- a/Services/IStorage.cs
+++ b/Services/IStorage.cs
@@ -1,389 +1,187 @@
-using decePubClient.Helpers;
+using decePubClient.Helpers;
using decePubClient.Models;
-using decePubClient.Models.Types;
-using Markdig;
+
+using PrivaPub.ClientModels;
namespace decePubClient.Services
{
+ ///
+ /// 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.
+ ///
public interface IStorage
{
- ValueTask> GetMessages();
- ValueTask GetMessage(string messageId);
- ValueTask AddMessages(List messages);
- ValueTask UpdateMessages(List messages);
- ValueTask RemoveMessage(string messageId);
+ Task> GetMessages(CancellationToken cancellationToken = default);
- ValueTask> GetClientLogs();
- ValueTask AddLog(Exception exception, string where);
- ValueTask AddLog(string message, string where);
+ Task GetMessage(string messageId, CancellationToken cancellationToken = default);
- ValueTask RemoveAll(bool includeClientLogs = false);
+ Task AddMessages(List messages, CancellationToken cancellationToken = default);
+
+ Task UpdateMessages(List messages, CancellationToken cancellationToken = default);
+
+ Task RemoveMessage(string messageId, CancellationToken cancellationToken = default);
+
+ Task> GetClientLogs(CancellationToken cancellationToken = default);
+
+ Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default);
+
+ Task AddLog(string message, string where, CancellationToken cancellationToken = default);
+
+ Task RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default);
}
- public class Storage : IStorage
+ public class Storage(IndexedDb db, ILogger logger) : IStorage
{
- readonly IndexedDb _db;
- readonly ILogger _logger;
+ /// The client log keeps at most this many entries; past it the oldest half is dropped.
+ const int MaxLogs = 100;
- public Storage(IndexedDb indexedDb, ILogger logger)
+ #region Messages
+
+ public async Task GetMessage(string messageId, CancellationToken cancellationToken = default)
{
- _db = indexedDb;
- _logger = logger;
- }
-
- #region Messagges
-
- public async ValueTask GetMessage(string messageId)
- {
- await _db.OpenIndexedDb();
- var message = await _db.GetByKey(nameof(Message), messageId);
-
- if (message is null)
+ try
{
- var messages = await GetMessages();
+ cancellationToken.ThrowIfCancellationRequested();
+ await db.OpenIndexedDb();
+ var message = await db.GetByKey(nameof(Message), messageId);
+ if (message is not null)
+ return message;
+
+ var messages = await GetMessages(cancellationToken);
return messages.FirstOrDefault(m => m.MessageId == messageId);
}
-
- return message;
- }
-
- public async ValueTask> GetMessages()
- {
- await _db.OpenIndexedDb();
- var messages = await _db.GetAll(nameof(Message)) ?? new();
-
-
- if (messages.Count == 0)
+ catch (Exception ex)
{
- messages.Add(new()
- {
- 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"
- }
- },
- 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 = 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,
- });
+ logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetMessage)}");
+ return default;
}
-
- messages = messages.OrderByDescending(m => m.CreatedAt).ToList();
-
- return messages;
}
- public async ValueTask AddMessages(List messages)
+ /// The stored messages, newest first; while none are stored, the mock feed (Faker.SeedMessages).
+ public async Task> GetMessages(CancellationToken cancellationToken = default)
{
- await _db.OpenIndexedDb();
- var result = await _db.AddItems(nameof(Message), messages);
- _logger.LogInformation(result);
- return result;
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await db.OpenIndexedDb();
+ var messages = await db.GetAll(nameof(Message)) ?? [];
+ if (messages.Count == 0)
+ messages = Faker.SeedMessages();
+
+ return [.. messages.OrderByDescending(m => m.CreatedAt)];
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetMessages)}");
+ return [];
+ }
}
- public async ValueTask UpdateMessages(List messages)
- {
- await _db.OpenIndexedDb();
- var result = await _db.UpdateItems(nameof(Message), messages);
- _logger.LogInformation(result);
- return result;
+ public Task AddMessages(List messages, CancellationToken cancellationToken = default) =>
+ Run(nameof(AddMessages), async () => await db.AddItems(nameof(Message), messages), cancellationToken);
- }
+ public Task UpdateMessages(List messages, CancellationToken cancellationToken = default) =>
+ Run(nameof(UpdateMessages), async () => await db.UpdateItems(nameof(Message), messages), cancellationToken);
- public async ValueTask RemoveMessage(string messageId)
- {
- await _db.OpenIndexedDb();
- var result = await _db.DeleteByKey(nameof(Message), messageId);
- _logger.LogInformation(result);
- return result;
- }
+ public Task RemoveMessage(string messageId, CancellationToken cancellationToken = default) =>
+ Run(nameof(RemoveMessage), async () => await db.DeleteByKey(nameof(Message), messageId), cancellationToken);
#endregion
#region Logs
- public async ValueTask> GetClientLogs()
+ public async Task> GetClientLogs(CancellationToken cancellationToken = default)
{
try
{
- _logger.LogInformation($"opening on {nameof(GetClientLogs)}");
- await _db.OpenIndexedDb();
- return (await _db.GetAll(nameof(ClientLogs))).OrderByDescending(cl => cl.TimeStamp).ToArray();
+ cancellationToken.ThrowIfCancellationRequested();
+ await db.OpenIndexedDb();
+ var logs = await db.GetAll(nameof(ClientLogs)) ?? [];
+ return [.. logs.OrderByDescending(log => log.TimeStamp)];
}
catch (Exception ex)
{
- _logger.LogError(ex, ex.Message);
- return new List();
+ logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetClientLogs)}");
+ return [];
}
}
- public async ValueTask AddLog(Exception exception, string where)
+ public Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default) =>
+ AddLog(new ClientLogs
+ {
+ Where = where,
+ Exception = new()
+ {
+ Type = exception.GetType().ToString(),
+ HelpLink = exception.HelpLink,
+ HResult = exception.HResult.ToString(),
+ InnerExceptionMessage = exception.InnerException?.Message,
+ Message = exception.Message,
+ Source = exception.Source,
+ StackTrace = exception.StackTrace,
+ TargetSiteName = exception.TargetSite?.Name
+ }
+ }, cancellationToken);
+
+ public Task AddLog(string message, string where, CancellationToken cancellationToken = default) =>
+ AddLog(new ClientLogs { Where = where, WarningMessage = message }, cancellationToken);
+
+ /// Numbers the entry after the newest one and trims the log; never throws.
+ async Task AddLog(ClientLogs log, CancellationToken cancellationToken)
{
try
{
- await _db.OpenIndexedDb();
+ cancellationToken.ThrowIfCancellationRequested();
+ await db.OpenIndexedDb();
- var maxKey = await _db.GetMaxKey(nameof(ClientLogs));
- var minKey = await _db.GetMinKey(nameof(ClientLogs));
+ var maxKey = await db.GetMaxKey(nameof(ClientLogs));
+ var minKey = await db.GetMinKey(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)
- for (var i = minKey; i < minKey + 50; i++)
- _ = await _db.DeleteByKey(nameof(ClientLogs), i);
-
- maxKey += 1;
- var result = await _db.AddItems(nameof(ClientLogs), new()
- {
- new()
- {
- Id = maxKey,
- Where = where,
- Exception = new()
- {
- Type = exception.GetType().ToString(),
- HelpLink = exception.HelpLink,
- HResult = exception.HResult.ToString(),
- InnerExceptionMessage = exception.InnerException?.Message,
- Message = exception.Message,
- Source = exception.Source,
- StackTrace = exception.StackTrace,
- TargetSiteName = exception.TargetSite?.Name
- }
- }
- });
- _logger.LogDebug($"{nameof(AddLog)}() add logs result = {result}");
+ log.Id = maxKey + 1;
+ var result = await db.AddItems(nameof(ClientLogs), [log]);
+ logger.LogDebug($"{nameof(AddLog)}() add logs result = {result}");
}
catch (Exception ex)
{
- _logger.LogError(ex, ex.Message);
- }
- }
-
- public async ValueTask AddLog(string message, string where)
- {
- try
- {
- await _db.OpenIndexedDb();
-
- var maxKey = await _db.GetMaxKey(nameof(ClientLogs));
- var minKey = await _db.GetMinKey(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(nameof(ClientLogs), new()
- {
- new()
- {
- Id = maxKey,
- Where = where,
- Exception = null,
- WarningMessage = message
- }
- });
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, ex.Message);
+ logger.LogError(ex, $"{nameof(Storage)}.{nameof(AddLog)}");
}
}
#endregion
- public async ValueTask RemoveAll(bool includeClientLogs = false)
+ public Task 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);
+
+ /// Runs a write and turns its outcome into a WebResult.
+ async Task Run(string caller, Func> write, CancellationToken cancellationToken)
{
+ var result = new WebResult();
try
{
- _logger.LogInformation($"opening on {nameof(RemoveAll)}");
- await _db.OpenIndexedDb();
- _ = await _db.DeleteAll(nameof(Message));
- if (includeClientLogs)
- _ = await _db.DeleteAll(nameof(ClientLogs));
-
- return true;
+ cancellationToken.ThrowIfCancellationRequested();
+ await db.OpenIndexedDb();
+ logger.LogInformation("{Caller}: {Result}", caller, await write());
+ return result;
+ }
+ catch (OperationCanceledException)
+ {
+ return SUtility.DefaultTaskCancelledResult;
}
catch (Exception ex)
{
- _logger.LogError(ex, ex.Message);
- return default;
+ logger.LogError(ex, $"{nameof(Storage)}.{caller}");
+ return result.Invalidate(ex.Message, exception: ex);
}
}
-
}
}
diff --git a/Services/IndexedDb.cs b/Services/IndexedDb.cs
index 0667514..4c79e8a 100644
--- a/Services/IndexedDb.cs
+++ b/Services/IndexedDb.cs
@@ -1,14 +1,10 @@
-using DnetIndexedDb;
+using DnetIndexedDb;
using Microsoft.JSInterop;
namespace decePubClient.Services
{
- public class IndexedDb : IndexedDbInterop
+ public class IndexedDb(IJSRuntime jsRuntime, IndexedDbOptions options) : IndexedDbInterop(jsRuntime, options)
{
- public IndexedDb(IJSRuntime jsRuntime, IndexedDbOptions options)
- : base(jsRuntime, options)
- {
- }
}
}
diff --git a/Services/MessagesService.cs b/Services/MessagesService.cs
index 0ee3f5f..e41163f 100644
--- a/Services/MessagesService.cs
+++ b/Services/MessagesService.cs
@@ -1,74 +1,88 @@
-using decePubClient.Models;
+using collAnon.Client.Services;
+
+using decePubClient.Extensions;
+using decePubClient.Models;
using decePubClient.Models.Types;
+using PrivaPub.ClientModels;
+
namespace decePubClient.Services
{
- public class MessagesService
+ ///
+ /// 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.
+ ///
+ public class MessagesService(ILoggingService logger, CoalescingStringLocalizer localizer)
{
- readonly IStorage storage;
- readonly HttpClient http;
+ /// Posts a message or, with RootMessageId set, a reply; Data is the new Message.
+ public Task 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)
+ /// Opens a reply of the given scope to a message; Data is the message.
+ public Task ReplyMessage(Message messageToReply, MessageType messageType = MessageType.Public, CancellationToken cancellationToken = default) =>
+ Run(nameof(ReplyMessage), () => messageToReply, cancellationToken);
+
+ /// Boosts the message, or takes the boost back; Data is the message with its counter updated.
+ public Task BoostUnboostMessage(Message message, CancellationToken cancellationToken = default) =>
+ Run(nameof(BoostUnboostMessage), () =>
+ {
+ message.BoostsCounter += message.IsBoostedByCurrentUser ? -1 : 1;
+ message.IsBoostedByCurrentUser = !message.IsBoostedByCurrentUser;
+ return message;
+ }, cancellationToken);
+
+ /// Adds the message to the favourites, or removes it; Data is the updated message.
+ public Task FavouriteUnfavouriteMessage(Message message, CancellationToken cancellationToken = default) =>
+ Run(nameof(FavouriteUnfavouriteMessage), () =>
+ {
+ message.IsFavourite = !message.IsFavourite;
+ return message;
+ }, cancellationToken);
+
+ public Task DeleteMessage(Message message, CancellationToken cancellationToken = default) =>
+ Run(nameof(DeleteMessage), () => message, cancellationToken);
+
+ public Task BlockUserFromMessage(MessageUser messageUser, CancellationToken cancellationToken = default) =>
+ Run(nameof(BlockUserFromMessage), () => messageUser, cancellationToken);
+
+ public Task SilenceUserFromMessage(MessageUser messageUser, CancellationToken cancellationToken = default) =>
+ Run(nameof(SilenceUserFromMessage), () => messageUser, cancellationToken);
+
+ /// The mock's stand-in for an API call: the same success/failure handling, with the outcome built locally.
+ async Task Run(string caller, Func action, CancellationToken cancellationToken)
{
- this.storage = storage;
- http = clientFactory.CreateClient("default");
- }
-
- //Covers also direct message
- public async Task SubmitMessage(MessageForm messageForm)
- {
- //TODO
-
- await Task.Run(() => {});
- return new();
- }
-
- //Covers also direct message
- public async Task ReplyMessage(Message messageToReply, MessageType messageType = MessageType.Public)
- {
- //TODO
-
- await Task.Run(() => {});
- return messageToReply;
- }
-
- public async Task BoostUnboostMessage(Message messageToReply)
- {
- //TODO
-
- await Task.Run(() => {});
- return messageToReply;
- }
-
- public async Task FavouriteUnfavouriteMessage(Message messageToReply)
- {
- //TODO
-
- await Task.Run(() => {});
- return messageToReply;
- }
-
- public async Task DeleteMessage(Message messageToReply)
- {
- //TODO
-
- await Task.Run(() => {});
- return messageToReply;
- }
-
- public async Task BlockUserFromMessage(MessageUser messageUser)
- {
- //TODO
- await Task.Run(() => {});
- return messageUser;
- }
-
- public async Task SilenceUserFromMessage(MessageUser messageUser)
- {
- //TODO
-
- await Task.Run(() => {});
- return messageUser;
+ var result = new WebResult();
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await Task.Yield();
+ result.Data = action();
+ return result;
+ }
+ catch (Exception ex)
+ {
+ await logger.ProcessError(ex, $"{nameof(MessagesService)}.{caller}", cancellationToken);
+ return result.Invalidate(localizer["The action could not be completed."], exception: ex);
+ }
}
}
-}
\ No newline at end of file
+}
diff --git a/Services/ToastService.cs b/Services/ToastService.cs
new file mode 100644
index 0000000..1cafb7a
--- /dev/null
+++ b/Services/ToastService.cs
@@ -0,0 +1,55 @@
+namespace decePubClient.Services;
+
+public enum ToastSeverity
+{
+ Info,
+ Success,
+ Warning,
+ Error
+}
+
+public sealed record ToastMessage(Guid Id, ToastSeverity Severity, string Text);
+
+///
+/// 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.
+///
+public class ToastService
+{
+ const int MaxMessages = 12;
+
+ readonly List messages = [];
+
+ public event Action Changed;
+
+ public IReadOnlyList Messages => messages;
+
+ /// Shows a message; with closeAfter it closes itself, otherwise it stays until the user closes it.
+ 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);
+ }
+}
diff --git a/Services/TokenAuthStateProvider.cs b/Services/TokenAuthStateProvider.cs
index 3dd29db..7d8eeaf 100644
--- a/Services/TokenAuthStateProvider.cs
+++ b/Services/TokenAuthStateProvider.cs
@@ -1,4 +1,5 @@
using Blazored.LocalStorage;
+using decePubClient.Helpers;
using decePubClient.Models;
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;
@@ -6,22 +7,9 @@ using System.Text.Json;
namespace decePubClient.Services
{
- public class TokenAuthStateProvider : AuthenticationStateProvider
+ public class TokenAuthStateProvider(ILocalStorageService storage, IStorage dbStorage, ILogger logger)
+ : AuthenticationStateProvider
{
- readonly ILocalStorageService Storage;
- readonly IStorage DbStorage;
- readonly ILogger Logger;
- AuthData AuthData { get; set; }
-
- public TokenAuthStateProvider(ILocalStorageService storage,
- IStorage dbStorage,
- ILogger logger)
- {
- Storage = storage;
- DbStorage = dbStorage;
- Logger = logger;
- }
-
public void SetToken(/*string token, long expirationTicks = default*/)
{
//AuthData = await Storage.GetItemAsync(nameof(AuthData));
@@ -41,41 +29,40 @@ namespace decePubClient.Services
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
- public async ValueTask IsAuthenticatedAsync()
+ /// The stored token while it is still valid, otherwise null.
+ public async Task GetToken(CancellationToken cancellationToken = default)
{
- AuthData = await Storage.GetItemAsync(nameof(AuthData));
- if (AuthData is null)
- {
- AuthData = new();
- await Storage.SetItemAsync(nameof(AuthData), AuthData);
- }
- return AuthData.Token != null &&
- AuthData.TokenExpiration.HasValue &&
- AuthData.TokenExpiration.Value > DateTime.UtcNow.Ticks;
+ var authData = await storage.GetItemAsync(nameof(AuthData), cancellationToken);
+ if (string.IsNullOrEmpty(authData?.Token) || authData.TokenExpiration is not { } expiration || expiration <= DateTime.UtcNow.Ticks)
+ return null;
+ return authData.Token;
}
- public async Task LogoutAsync(bool deleteDb = false)
+ public async Task 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(AuthData));
+ await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
if (deleteDb)
- await DbStorage.RemoveAll(includeClientLogs: true);
+ await dbStorage.RemoveAll(includeClientLogs: true, cancellationToken: cancellationToken);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public override async Task GetAuthenticationStateAsync()
{
- AuthData = await Storage.GetItemAsync(nameof(AuthData));
- if (string.IsNullOrEmpty(AuthData?.Token))
+ var authData = await storage.GetItemAsync(nameof(AuthData));
+ if (string.IsNullOrEmpty(authData?.Token))
{
- Logger.LogInformation($"set null({nameof(GetAuthenticationStateAsync)})");
+ logger.LogInformation($"set null({nameof(GetAuthenticationStateAsync)})");
return new(new());
}
var claims = new List
{
- 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()));
diff --git a/Shared/LoginDisplay.razor b/Shared/LoginDisplay.razor
index c051e3f..1e21290 100644
--- a/Shared/LoginDisplay.razor
+++ b/Shared/LoginDisplay.razor
@@ -1,5 +1,4 @@
@inherits LocalizableComponentBase
-@inject NavigationManager Navigation
@@ -15,7 +14,6 @@
@code {
- [CascadingParameter] CascadingState CascadingState { get; set; }
private void BeginSignOut(MouseEventArgs args)
{
Navigation.NavigateTo("logout");
diff --git a/Shared/MainLayout.razor b/Shared/MainLayout.razor
index b5e0b6e..913c022 100644
--- a/Shared/MainLayout.razor
+++ b/Shared/MainLayout.razor
@@ -8,4 +8,6 @@
@Body
-
\ No newline at end of file
+
+
+
\ No newline at end of file
diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor
index ebc0ba1..e61df51 100644
--- a/Shared/NavMenu.razor
+++ b/Shared/NavMenu.razor
@@ -9,19 +9,19 @@