diff --git a/CHANGELOG.md b/CHANGELOG.md index 16ae2b264..e1a3be3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,33 @@ # Changelog + +## [1.0.8-rc24] - unstable + +### Added + +* [PostIt] Reconnection automatique aux salons de discussion au reconnect + +### 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 +* [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/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.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. 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/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 => { diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index 5c926bdee..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 @@ - + - - - - + + @@ -126,7 +122,27 @@ - + + + + + + + + + @@ -197,6 +219,5 @@ - - + diff --git a/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs b/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs index bdb2ccdc8..3f9b30d38 100644 --- a/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs +++ b/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs @@ -1,12 +1,16 @@ #nullable enable annotations using Yavsc.Abstract.Identity.Security; +using System.ComponentModel; using System.Text.Json.Serialization; namespace Yavsc.Blogspot; -public class BlogPostDto : IBlogPost +public class BlogPostDto : IBlogPost, INotifyPropertyChanged { + public event PropertyChangedEventHandler? PropertyChanged; + private bool _isPublished; + public string AuthorId { get; set; } public BlogPostAuthorDto? Author { get; set; } @@ -30,7 +34,18 @@ public class BlogPostDto : IBlogPost /// follow-up request. Toggled via /// 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) {