From e7721bc3ab3e5575dca34f8ba8f8c537cc8757f3 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 9 Oct 2026 17:46:32 +0100 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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 @@ - - +