From 3f73d7a9094f0f714baff8ee7b2a5d0defb8a371 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 9 Oct 2026 17:14:41 +0100 Subject: [PATCH 01/26] co-auteurs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1cee99bbb..79e711258 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d * ~/Views/Home/Index.cshtml * ~/Views/Home/About.cshtml -# 👥 Crédits & Co-autoraClaudt +# 👥 Crédits & Co-auteurs Ce projet a été développé de manière hybride : - **Conception, architecture et revue de code :** Paul Schneider From e7721bc3ab3e5575dca34f8ba8f8c537cc8757f3 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 9 Oct 2026 17:46:32 +0100 Subject: [PATCH 02/26] feat(postit): show a live published badge beside blog post titles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 ++ src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml | 22 ++++++++++++++++++- src/Yavsc.Abstract/Blogspot/BlogPostDto.cs | 19 ++++++++++++++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16ae2b264..5b788ad62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added +* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la + liste, mis à jour lors de la publication ou du retour en brouillon. * [PostIt] Gestion native du compte inspirée de `/Manage` : modification du nom d'utilisateur, du nom complet et de l'adresse, téléversement d'avatar, confirmation e-mail, préférence de courriel mensuel, diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index 5c926bdee..eb88fb2d8 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -126,7 +126,27 @@ - + + + + + + PUT /api/BlogApi/{id}/publish. /// - public bool IsPublished { get; set; } + public bool IsPublished + { + get => _isPublished; + set + { + if (_isPublished == value) + return; + + _isPublished = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsPublished))); + } + } public virtual bool AuthorizeCircle(long circleId) { From 95e8f251d20159928e297f8540ec40a0699318f5 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 9 Oct 2026 17:47:01 +0100 Subject: [PATCH 03/26] test(postit): verify published badge and publication state notifications Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PostIt.Tests/PostItViewModelTests.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index 8ecc5bb55..bf7023997 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -1,12 +1,47 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; using Yavsc.Blogspot; using Yavsc.Api.Client; using PostIt.Services; using PostIt.ViewModels; +using PostIt.Views.Blogs; namespace PostIt.Tests; public class PostItViewModelTests { + [AvaloniaFact] + public void Published_badge_tracks_post_publication_state() + { + var post = new BlogPostDto { Id = 42, Title = "Test post", IsPublished = true }; + var page = new BlogsPage(); + var row = page.PostsListBox.ItemTemplate!.Build(post)!; + row.DataContext = post; + var window = new Window { Content = row }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + var badge = Assert.Single(row.GetVisualDescendants().OfType(), + border => border.Name == "PublishedBadge"); + + Assert.True(badge.IsVisible); + + post.IsPublished = false; + Dispatcher.UIThread.RunJobs(); + Assert.False(badge.IsVisible); + + post.IsPublished = true; + Dispatcher.UIThread.RunJobs(); + Assert.True(badge.IsVisible); + } + finally + { + window.Close(); + } + } [Fact] public void SearchCommand_filters_posts_by_title_article_or_author() @@ -61,11 +96,22 @@ public class PostItViewModelTests var viewModel = new BlogsViewModel(blog); viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false }; + var changes = new List(); + viewModel.SelectedPost.PropertyChanged += (_, args) => changes.Add(args.PropertyName); await viewModel.SetPublishStateAsync(true); Assert.True(api.LastPublishValue); Assert.True(viewModel.DraftIsPublished); + Assert.True(viewModel.SelectedPost.IsPublished); + Assert.Equal(new[] { nameof(BlogPostDto.IsPublished) }, changes); + + await viewModel.SetPublishStateAsync(false); + + Assert.False(api.LastPublishValue); + Assert.False(viewModel.DraftIsPublished); + Assert.False(viewModel.SelectedPost.IsPublished); + Assert.Equal(new[] { nameof(BlogPostDto.IsPublished), nameof(BlogPostDto.IsPublished) }, changes); } /// Test fake that always throws if the API is invoked. From cd7351a783da0116d36eb11b623de7392ca6570d Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 9 Oct 2026 17:55:03 +0100 Subject: [PATCH 04/26] feat(postit): rejoin chat room automatically after reconnect Also drop unused usings in BlogsViewModel. --- CHANGELOG.md | 11 +++++++++++ src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs | 2 -- src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs | 8 ++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b788ad62..8c944075a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog + +## [1.0.8-rc24] - unstable + +### Added + +* [PostIt] Reconnection automatique aux salons de discussion au reconnect + +### Changed + +### Fixed + ## [1.0.8-rc23] - unstable ### Added diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs index b827bff26..882b5d19e 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -10,9 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Yavsc.Blogspot; using Yavsc.Api.Client; using Yavsc.Abstract.Files; -using PostIt.Helpers; using AvaloniaEdit.Document; -using PostIt.Models; namespace PostIt.ViewModels; diff --git a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs index ba452cb4a..7d5b516d6 100644 --- a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs @@ -246,7 +246,7 @@ namespace PostIt.ViewModels.Chat }); return Task.CompletedTask; }; - _connection.Reconnected += _ => + _connection.Reconnected += async _ => { Avalonia.Threading.Dispatcher.UIThread.Post(() => { @@ -254,7 +254,11 @@ namespace PostIt.ViewModels.Chat Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info, new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync())); }); - return Task.CompletedTask; + // La reconnexion crée une nouvelle ConnectionId côté + // serveur : l'appartenance aux salons est perdue, il + // faut rejoindre à nouveau le salon courant. + if (!string.IsNullOrWhiteSpace(CurrentRoom)) + await JoinRoomAsync(); }; _connection.Closed += error => { From ebdf51f46812581b447ed6d6fc9b3ea4c4ac66da Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 9 Oct 2026 18:28:21 +0100 Subject: [PATCH 05/26] fix(postit): restore Markdown editor and preview scrolling Constrain the blog page layout, separate list and article scrolling, and remove content height limits and nested editor scrolling. Add six headless regression cases and update the changelog and layout documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 10 ++ doc/architecture/postit.md | 14 ++ .../PostIt.Tests/MarkdownEditorScrollTests.cs | 143 ++++++++++++++++++ .../Controls/MarkdownEditorControl.axaml | 51 +++---- .../Controls/MarkdownEditorControl.axaml.cs | 25 --- src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml | 29 ++-- 6 files changed, 205 insertions(+), 67 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c944075a..e1a3be3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,18 @@ ### Changed +* [PostIt] La liste des billets et l'éditeur Markdown se partagent + l'espace disponible et défilent séparément ; un séparateur permet + de redimensionner les deux zones. + ### Fixed +* [PostIt] Défilement des articles longs en édition et en aperçu : + suppression de la limite de hauteur qui masquait le contenu Markdown + et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless + couvrent les barres automatiques, le redimensionnement et le + défilement indépendant de la liste. + ## [1.0.8-rc23] - unstable ### Added diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 70f3f2fdc..b1bf83cf7 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -126,6 +126,20 @@ le DI est construit. Ordre, dans cet ordre : > à cette table, *ne pas* ajouter de registration pour > `Settings` ailleurs que la ligne `AddSingleton(settings)`. +## Défilement de la page des billets + +La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête +et les commandes restent visibles, tandis que la liste des billets et +l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet +de modifier cette répartition. La liste et l'article défilent séparément. + +Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ; +l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter +la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir +dépasser le viewport pour que les barres automatiques s'activent. +Ne pas placer cette grille dans un `StackPanel` vertical ou un +`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée. + ## Navigation Le host de navigation est un `NavigationPage x:Name="NavRoot"` diff --git a/src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs b/src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs new file mode 100644 index 000000000..1a83330f9 --- /dev/null +++ b/src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs @@ -0,0 +1,143 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless.XUnit; +using Avalonia.LogicalTree; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvaloniaEdit; +using PostIt.Controls; +using PostIt.Views.Blogs; + +namespace PostIt.Tests; + +public class MarkdownEditorScrollTests +{ + private static readonly string LongArticle = + string.Join("\n\n", Enumerable.Range(1, 100).Select(i => $"Paragraph {i}: article content.")); + + [AvaloniaTheory] + [InlineData(false)] + [InlineData(true)] + public void Long_article_scrolls_to_the_end_and_adapts_to_resizing(bool preview) + { + var editor = new MarkdownEditorControl + { + MarkdownText = LongArticle, + IsPreviewVisible = preview + }; + var window = new Window { Width = 600, Height = 400, Content = editor }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + var scroller = FindArticleScroller(editor, preview); + AssertScrollable(scroller); + var initialViewport = scroller.Viewport.Height; + + window.Height = 300; + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.True(scroller.Viewport.Height < initialViewport); + AssertScrollable(scroller); + } + finally + { + CloseWindow(window, editor); + } + } + + [AvaloniaTheory] + [InlineData(false)] + [InlineData(true)] + public void Blog_page_bounds_the_article_viewport(bool preview) + { + var page = new BlogsPage(); + var editor = page.GetLogicalDescendants().OfType().Single(); + editor.MarkdownText = LongArticle; + editor.IsPreviewVisible = preview; + var posts = page.FindControl("PostsListBox")!; + posts.ItemsSource = Enumerable.Range(1, 50) + .Select(i => new Yavsc.Blogspot.BlogPostDto { Title = $"Post {i}" }).ToArray(); + var navigation = new NavigationPage(); + navigation.PushAsync(page).GetAwaiter().GetResult(); + var window = new Window { Width = 800, Height = 700, Content = navigation }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + var scroller = FindArticleScroller(editor, preview); + Assert.InRange(scroller.Viewport.Height, 1, window.ClientSize.Height); + AssertScrollable(scroller); + AssertScrollable(posts.GetVisualDescendants().OfType().Single()); + var editorBottom = editor.TranslatePoint(new Point(0, editor.Bounds.Height), window); + Assert.NotNull(editorBottom); + Assert.InRange(editorBottom.Value.Y, 1, window.ClientSize.Height); + } + finally + { + CloseWindow(window, editor); + } + } + + [AvaloniaTheory] + [InlineData(false)] + [InlineData(true)] + public void Short_article_needs_no_vertical_scrollbar(bool preview) + { + var editor = new MarkdownEditorControl + { + MarkdownText = "Short article", + IsPreviewVisible = preview + }; + var window = new Window { Width = 600, Height = 400, Content = editor }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + var scroller = FindArticleScroller(editor, preview); + Assert.True(scroller.Extent.Height <= scroller.Viewport.Height); + Assert.DoesNotContain(scroller.GetVisualDescendants().OfType(), + bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible); + } + finally + { + CloseWindow(window, editor); + } + } + + private static void CloseWindow(Window window, MarkdownEditorControl editor) + { + // The package's shared renderer caches the visual; detach it between headless applications. + editor.FindControl("PreviewViewer")!.Content = null; + window.UpdateLayout(); + window.Close(); + } + + private static ScrollViewer FindArticleScroller(MarkdownEditorControl editor, bool preview) + { + if (!preview) + return editor.GetVisualDescendants().OfType().Single() + .GetVisualDescendants().OfType().Single(); + + return editor.FindControl("PreviewScrollViewer")!; + } + + private static void AssertScrollable(ScrollViewer scroller) + { + Assert.True(scroller.Extent.Height > scroller.Viewport.Height, + $"Expected overflow, but extent={scroller.Extent.Height}, viewport={scroller.Viewport.Height}."); + Assert.Contains(scroller.GetVisualDescendants().OfType(), + bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible); + scroller.ScrollToEnd(); + Assert.True(scroller.Offset.Y > 0); + Assert.Equal(scroller.Extent.Height - scroller.Viewport.Height, scroller.Offset.Y, 1); + } +} diff --git a/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml index 9e8df6dd1..721071d2e 100644 --- a/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml +++ b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml @@ -7,35 +7,27 @@ x:Name="Root" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> - + - + - - + TextInputOptions.Multiline="True" + TextInputOptions.ReturnKeyType="Default" /> + Padding="8"> + VerticalAlignment="Stretch" + HorizontalScrollBarVisibility="Auto"> + HorizontalContentAlignment="Stretch" + VerticalAlignment="Top" + ClipToBounds="False" + UseLayoutRounding="True" + MarkdownText="{Binding MarkdownText, ElementName=Root}" /> diff --git a/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs index 0431b726e..2538f7168 100644 --- a/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs +++ b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs @@ -38,8 +38,6 @@ public partial class MarkdownEditorControl : UserControl { InitializeComponent(); PropertyChanged += OnPropertyChanged; - SizeChanged += OnSizeChanged; - UpdateEditorLimits(); } private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) @@ -62,27 +60,4 @@ public partial class MarkdownEditorControl : UserControl } } } - - private void OnSizeChanged(object? sender, SizeChangedEventArgs e) - { - UpdateEditorLimits(); - } - - private void UpdateEditorLimits() - { - var visibleHeight = Bounds.Height > 0 ? Bounds.Height - 24 : 260; - visibleHeight = Math.Max(visibleHeight, 80); - - if (SourceEditor is not null) - { - SourceEditor.MaxHeight = visibleHeight; - SourceEditor.Height = double.NaN; - } - - if (PreviewViewer is not null) - { - PreviewViewer.MaxHeight = visibleHeight; - PreviewViewer.Height = double.NaN; - } - } } diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index eb88fb2d8..7da9a4ce8 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -17,14 +17,11 @@ - - + VerticalAlignment="Stretch"> @@ -107,16 +105,14 @@ - + - - - - + + @@ -161,6 +157,11 @@ + + + @@ -217,6 +219,5 @@ - - + From 7ee0c3050013bf98160ced4a80c74f5622a00690 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 10 Oct 2026 20:03:19 +0100 Subject: [PATCH 06/26] Fix authenticated resource usage tracking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ src/Yavsc.Api/Program.cs | 2 +- src/Yavsc.Blogs/Program.cs | 2 +- .../ResourceUsageApiControllerTests.cs | 17 +++++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a3be3a2..36ad580b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog +## [1.0.8-rc25] - unstable +### Added + +### Changed + +### Fixed + +* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont + désormais enregistrés côté serveur pour les requêtes authentifiées : + le middleware de télémétrie est maintenant branché après + l'authentification, ce qui permet de comptabiliser `ApiCalls` et + `BandwidthMb` au lieu d'enregistrer des zéros. ## [1.0.8-rc24] - unstable diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index 3545774ae..be9601d49 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -134,11 +134,11 @@ internal class Program if (app.Environment.IsDevelopment()) app.UseDeveloperExceptionPage(); - app.UseMiddleware(); app .UseRouting() .UseAuthentication() .UseAuthorization() + .UseMiddleware() .UseCors("default"); app.MapIdentityApi().RequireAuthorization("ApiScope"); app.MapDefaultControllerRoute(); diff --git a/src/Yavsc.Blogs/Program.cs b/src/Yavsc.Blogs/Program.cs index 21a26e637..6d69c6785 100644 --- a/src/Yavsc.Blogs/Program.cs +++ b/src/Yavsc.Blogs/Program.cs @@ -98,11 +98,11 @@ internal class Program { if (app.Environment.IsDevelopment()) app.UseDeveloperExceptionPage(); - app.UseMiddleware(); app .UseRouting() .UseAuthentication() .UseAuthorization() + .UseMiddleware() .UseCors("default") ; app.Services.GetRequiredService().CreateLogger("Program") diff --git a/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs b/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs index 8b8347aae..33b3f392a 100644 --- a/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs +++ b/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs @@ -98,6 +98,23 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture= 3.5m); } + [Fact] + public async Task GetCurrentUserUsage_records_telemetry_values_from_authenticated_requests() + { + using var http = NewClient(_fixture, "telemetry-user"); + + var response = await http.GetAsync("/api/v1/resource-usage/me", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var record = await db.ResourceUsageRecords + .SingleAsync(r => r.UserId == "telemetry-user"); + + Assert.True(record.ApiCalls >= 1m); + Assert.True(record.BandwidthMb > 0m); + } + [Fact] public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user() { From 8b4b6a7c53021db2dabd18004e88a204d19bce94 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 10 Oct 2026 20:23:55 +0100 Subject: [PATCH 07/26] Add notifications API for PostIt home page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++ src/Yavsc.Abstract/Constants.cs | 2 + .../NotificationsApiClient.cs | 58 +++++++++++++++++++ .../NotificationsApiController.cs | 50 ++++++++++++++++ .../ResourceUsageApiControllerTests.cs | 44 ++++++++++++++ 5 files changed, 158 insertions(+) create mode 100644 src/Yavsc.Api.Client/NotificationsApiClient.cs create mode 100644 src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ad580b9..eaeca5faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Added +* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me` + pour exposer les notifications publiques et ciblées à PostIt sur la + Home page ; les notifications d'administration restent filtrées. + ### Changed ### Fixed diff --git a/src/Yavsc.Abstract/Constants.cs b/src/Yavsc.Abstract/Constants.cs index 0d06582bf..5883fea43 100644 --- a/src/Yavsc.Abstract/Constants.cs +++ b/src/Yavsc.Abstract/Constants.cs @@ -33,6 +33,8 @@ namespace Yavsc public const string ResourceUsagePath = "resource-usage"; public const string ResourceUsageCurrentUserRoute = "me"; public const string ResourceUsageAdminRoute = "admin"; + public const string NotificationsPath = "notifications"; + public const string NotificationsCurrentUserRoute = "me"; public const string ResourceUsageDefaultCurrency = "EUR"; public const string ResourceUsageCurrentUserDisplayName = "current-user"; public const string ResourceUsageAdminDisplayName = "administrator"; diff --git a/src/Yavsc.Api.Client/NotificationsApiClient.cs b/src/Yavsc.Api.Client/NotificationsApiClient.cs new file mode 100644 index 000000000..3e93deb25 --- /dev/null +++ b/src/Yavsc.Api.Client/NotificationsApiClient.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Abstract.Models.Messaging; + +namespace Yavsc.Api.Client; + +/// +/// Thin HTTP client for the notifications exposed to PostIt on the home page. +/// +public sealed class NotificationsApiClient +{ + private const string PathPrefix = Constants.NotificationsPath; + + private readonly IYavscApiClient _api; + private readonly Func _businessBaseAddress; + + public NotificationsApiClient(IYavscApiClient api, string businessBaseAddress) + : this(api, () => businessBaseAddress) + { + } + + public NotificationsApiClient(IYavscApiClient api, Func businessBaseAddress) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress)); + _ = ResolveBusinessBaseAddress(); + } + + public Task> GetCurrentAsync(CancellationToken ct = default) + { + return _api.CallAsync>( + HttpMethod.Get, + Absolute($"{PathPrefix}/{Constants.NotificationsCurrentUserRoute}"), + ct: ct); + } + + public Task> GetAllAsync(CancellationToken ct = default) + { + return _api.CallAsync>( + HttpMethod.Get, + Absolute(PathPrefix), + ct: ct); + } + + private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString(); + + private Uri ResolveBusinessBaseAddress() + { + var raw = _businessBaseAddress(); + if (string.IsNullOrWhiteSpace(raw)) + throw new InvalidOperationException("Business base address is required."); + + return new Uri(raw, UriKind.Absolute); + } +} diff --git a/src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs b/src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs new file mode 100644 index 000000000..7053a5acc --- /dev/null +++ b/src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Yavsc.Abstract.Models.Messaging; +using Yavsc.Models; +using Yavsc.Server.Helpers; + +namespace Yavsc.ApiControllers.Administration; + +[Produces("application/json")] +[Route(Constants.APIPrefix + "/" + Constants.NotificationsPath)] +public sealed class NotificationsApiController : Controller +{ + private readonly ApplicationDbContext _dbContext; + + public NotificationsApiController(ApplicationDbContext dbContext) + { + _dbContext = dbContext; + } + + [HttpGet] + [HttpGet(Constants.NotificationsCurrentUserRoute)] + [AllowAnonymous] + public async Task>> GetCurrentUserNotifications() + { + var currentUserId = User.GetUserId(); + var isAdmin = User.IsInRole(Constants.AdminGroupName); + + var dismissedIds = string.IsNullOrWhiteSpace(currentUserId) + ? new HashSet() + : await _dbContext.DismissClicked + .AsNoTracking() + .Where(d => d.UserId == currentUserId) + .Select(d => d.NotificationId) + .ToHashSetAsync(); + + var notifications = await _dbContext.Notification + .AsNoTracking() + .Where(n => !dismissedIds.Contains(n.Id)) + .Where(n => + string.IsNullOrWhiteSpace(n.Target) + || (n.Target != null && n.Target.StartsWith("pub/")) + || (!string.IsNullOrWhiteSpace(currentUserId) && n.Target == $"user/{currentUserId}") + || (isAdmin && n.Target == "administration")) + .OrderByDescending(n => n.Id) + .ToListAsync(); + + return Ok(notifications); + } +} diff --git a/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs b/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs index 33b3f392a..9bb278551 100644 --- a/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs +++ b/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs @@ -4,6 +4,7 @@ using System.Net.Http.Json; using System.Security.Claims; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Yavsc.Abstract.Models.Messaging; using Yavsc.Abstract.Resources; using Yavsc.Api.Test.Fixtures; using Yavsc.Server.Helpers; @@ -115,6 +116,49 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture 0m); } + [Fact] + public async Task GetCurrentUserNotifications_returns_public_and_targeted_notifications() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.Notification.RemoveRange(db.Notification); + await db.Notification.AddRangeAsync( + new Notification + { + title = "Public", + body = "Seen by everyone", + click_action = "/public", + Target = null, + }, + new Notification + { + title = "User", + body = "Private to current user", + click_action = "/profile", + Target = "user/telemetry-user", + }, + new Notification + { + title = "Admin", + body = "Restricted to administrators", + click_action = "/admin", + Target = "administration", + }); + await db.SaveChangesAsync(); + + using var http = NewClient(_fixture, "telemetry-user"); + + var response = await http.GetAsync("/api/v1/notifications/me", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var notifications = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken); + Assert.NotNull(notifications); + Assert.Contains(notifications!, n => n.title == "Public"); + Assert.Contains(notifications!, n => n.title == "User"); + Assert.DoesNotContain(notifications!, n => n.title == "Admin"); + } + [Fact] public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user() { From fae754f1026835da89a5ab7459d8f100501de19b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 10 Oct 2026 20:54:55 +0100 Subject: [PATCH 08/26] Display notifications on PostIt home page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + .../HomePageNotificationsTests.cs | 199 ++++++++++++++++++ .../PostIt.Tests/YavscApiClientTests.cs | 50 +++++ .../Helpers/ServiceCollectionHelpers.cs | 2 + src/PostIt/PostIt/Services/YavscApiClient.cs | 13 +- .../ViewModels/Layout/HomePageViewModel.cs | 87 +++++++- src/PostIt/PostIt/Views/Layout/HomePage.axaml | 37 +++- .../PostIt/Views/Layout/HomePage.axaml.cs | 9 + src/Yavsc.Api.Client/IYavscApiClient.cs | 4 + .../NotificationsApiClient.cs | 3 + 10 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index eaeca5faa..9b897b724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ * [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me` pour exposer les notifications publiques et ciblées à PostIt sur la Home page ; les notifications d'administration restent filtrées. +* [PostIt] Affichage des notifications sur la page d'accueil, avec + actualisation, indicateur de chargement et message d'erreur. Les + notifications publiques sont accessibles sans connexion et la liste + est rechargée lors d'un changement de session. ### Changed diff --git a/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs new file mode 100644 index 000000000..667ea599a --- /dev/null +++ b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs @@ -0,0 +1,199 @@ +using System.Net.Http; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; +using Yavsc.Abstract.Models.Messaging; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class HomePageNotificationsTests +{ + [Fact] + public async Task Refresh_loads_public_notifications_from_the_business_host() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + transport.Response = Task.FromResult(new List { Note(1, "Information") }); + + await vm.RefreshNotificationsAsync(); + + Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath); + Assert.True(transport.WasAnonymous); + Assert.Equal("Information", Assert.Single(vm.Notifications).title); + Assert.False(vm.IsLoadingNotifications); + Assert.False(vm.HasNoNotifications); + Assert.Null(vm.NotificationsError); + } + + [Fact] + public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + vm.Notifications.Add(Note(1, "Old")); + await vm.RefreshNotificationsAsync(); + + Assert.Empty(vm.Notifications); + Assert.True(vm.HasNoNotifications); + + transport.Response = Task.FromException>(new HttpRequestException("Server unavailable")); + await vm.RefreshNotificationsAsync(); + + Assert.Empty(vm.Notifications); + Assert.False(vm.HasNoNotifications); + Assert.Contains("Server unavailable", vm.NotificationsError); + Assert.False(vm.IsLoadingNotifications); + + transport.Response = Task.FromResult(new List { Note(2, "Recovered") }); + await vm.RefreshNotificationsAsync(); + Assert.Null(vm.NotificationsError); + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + } + + [Fact] + public async Task Refresh_ignores_an_older_response_after_a_newer_refresh() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + var oldResponse = new TaskCompletionSource>(); + transport.Response = oldResponse.Task; + var oldLoad = vm.RefreshNotificationsAsync(); + Assert.True(vm.IsLoadingNotifications); + + transport.Response = Task.FromResult(new List { Note(2, "Current") }); + await vm.RefreshNotificationsAsync(); + oldResponse.SetResult(new List { Note(1, "Stale") }); + await oldLoad; + + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + Assert.False(vm.IsLoadingNotifications); + } + + [AvaloniaFact] + public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data() + { + var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json"); + var settings = new Settings(); + var store = new TokenStore(tokenPath); + store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", + DateTimeOffset.UtcNow.AddHours(1), null)); + await using var api = new YavscApiClient(settings, store); + try + { + var session = new SessionStatusViewModel { Api = api }; + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport, session); + session.Refresh(); + Assert.False(transport.WasAnonymous); + Assert.EndsWith("/notifications/me", transport.LastPath); + var privateResponse = new TaskCompletionSource>(); + transport.Response = privateResponse.Task; + var privateLoad = vm.RefreshNotificationsAsync(); + Assert.False(transport.WasAnonymous); + Assert.EndsWith("/notifications/me", transport.LastPath); + + vm.Notifications.Add(Note(9, "Private")); + transport.Response = Task.FromResult(new List { Note(2, "Public") }); + session.SetError("Disconnected"); + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + Assert.True(transport.WasAnonymous); + + privateResponse.SetResult(new List { Note(1, "Late private data") }); + await privateLoad; + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + } + finally + { + File.Delete(tokenPath); + } + } + + [AvaloniaFact] + public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies() + { + var transport = new NotificationTransport + { + Response = Task.FromResult(new List { Note(1, "Notification title") }) + }; + var vm = CreateViewModel(transport); + var page = new HomePage { DataContext = vm }; + var nav = new NavigationPage(); + var window = new Window { Content = nav, Width = 320, Height = 450 }; + try + { + window.Show(); + await nav.PushAsync(page); + window.UpdateLayout(); + + Assert.Single(vm.Notifications); + var texts = page.GetVisualDescendants().OfType().ToArray(); + Assert.Contains(texts, t => t.Text == "Notification title" + && t.TextWrapping == Avalonia.Media.TextWrapping.Wrap); + Assert.Contains(texts, t => t.Text == "Notification body" + && t.TextWrapping == Avalonia.Media.TextWrapping.Wrap); + var refresh = Assert.Single(page.GetVisualDescendants().OfType