feat(PostIt): add dedicated blog reader and video playback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-16 13:31:57 +01:00
commit c1254a522f
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
18 changed files with 827 additions and 20 deletions

View file

@ -21,6 +21,7 @@
<PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />

View file

@ -0,0 +1,201 @@
using System.Net;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Markdown.Avalonia;
using ColorTextBlock.Avalonia;
using PostIt.Models;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Blogs;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
namespace PostIt.Tests;
public class BlogReaderTests
{
private static BlogPostDto Post(string article = "Texte du billet.") => new()
{
Id = 42,
Title = "Un billet à lire",
Article = article,
AuthorId = "user-id-not-login",
Author = new BlogPostAuthorDto { UserName = "paul" },
DateModified = DateTime.UtcNow
};
private static BlogsViewModel CreateViewModel(BlogResourceService? resources = null)
=> new(new BlogApiClient(new RecordingYavscApiClient(new CallRecorder()), "https://blogs.example.test/api/v1/"),
new Settings { Authentication = new AuthenticationSettings { Authority = "https://org.example.test" } },
resources: resources);
[Fact]
public void Selecting_a_post_defaults_to_reading_and_uses_the_author_login()
{
var vm = CreateViewModel();
vm.SelectedPost = Post();
Assert.False(vm.IsEditing);
Assert.Equal("paul", vm.ReaderAuthor);
Assert.Equal("https://org.example.test/files/paul/", vm.ReaderBaseUrl);
Assert.Equal("Texte du billet.", vm.ReaderArticle);
}
[Fact]
public void Reading_does_not_publish_or_discard_unsaved_editor_changes()
{
var vm = CreateViewModel();
vm.SelectedPost = Post();
vm.EditPostCommand.Execute(null);
vm.DraftArticle = "Modifications non enregistrées";
vm.ReadPostCommand.Execute(null);
Assert.False(vm.IsEditing);
Assert.Equal("Texte du billet.", vm.ReaderArticle);
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
Assert.True(vm.IsDraftModified);
vm.EditPostCommand.Execute(null);
Assert.True(vm.IsEditing);
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
}
[Fact]
public void New_post_is_an_explicit_action_and_protects_unsaved_changes()
{
var vm = CreateViewModel();
vm.SelectedPost = Post();
vm.NewPostCommand.Execute(null);
Assert.True(vm.IsEditing);
Assert.Null(vm.SelectedPost);
Assert.Empty(vm.DraftTitle);
Assert.Empty(vm.DraftArticle!);
vm.DraftTitle = "Nouveau brouillon";
vm.NewPostCommand.Execute(null);
Assert.Equal("Nouveau brouillon", vm.DraftTitle);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
}
[AvaloniaFact]
public async Task Reader_hides_editor_and_wires_hyperlinks_to_the_application()
{
var vm = CreateViewModel();
await vm.InitializeAsync();
vm.SelectedPost = Post("[Vidéo](clip.mp4)");
var page = new BlogsPage { DataContext = vm };
var nav = new NavigationPage();
await nav.PushAsync(page);
var window = new Window { Width = 800, Height = 700, Content = nav };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Assert.True(page.FindControl<Grid>("PostReader")!.IsVisible);
Assert.False(page.FindControl<Grid>("PostEditor")!.IsVisible);
Assert.False(page.SaveButton.IsVisible);
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
Assert.Equal(vm.ReaderArticle, reader.Markdown);
Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot);
Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine).HyperlinkCommand);
vm.EditPostCommand.Execute(null);
Assert.False(page.FindControl<Grid>("PostReader")!.IsVisible);
Assert.True(page.FindControl<Grid>("PostEditor")!.IsVisible);
Assert.True(page.SaveButton.IsVisible);
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed()
{
var vm = CreateViewModel();
await vm.InitializeAsync();
vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}.")));
var page = new BlogsPage { DataContext = vm };
var nav = new NavigationPage();
await nav.PushAsync(page);
var window = new Window { Width = 800, Height = 700, Content = nav };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
var scroll = reader.GetVisualDescendants().OfType<ScrollViewer>().Single();
Assert.InRange(scroll.Viewport.Height, 1, window.ClientSize.Height);
Assert.True(scroll.Extent.Height > scroll.Viewport.Height);
scroll.ScrollToEnd();
Assert.True(scroll.Offset.Y > 0);
vm.SelectedPost = null;
Dispatcher.UIThread.RunJobs();
Assert.Empty(reader.Markdown!);
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel()
{
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
{
Assert.Equal("https://org.example.test/files/paul/clip.mp4", request.RequestUri!.AbsoluteUri);
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Content.Headers.ContentType = new("video/mp4");
return response;
}));
var vm = CreateViewModel(new BlogResourceService(client));
vm.SelectedPost = Post();
var app = (App)Application.Current!;
var root = new MainView();
app.AttachMainWindow(root);
var template = new FuncDataTemplate<VideoPageViewModel>((_, _) => new ContentPage());
app.DataTemplates.Insert(0, template);
try
{
var page = new BlogsPage { DataContext = vm };
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
Dispatcher.UIThread.RunJobs();
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
var link = Assert.IsType<CHyperlink>(Assert.Single(engine.ParseGamutInline("[Vidéo](clip.mp4)")));
Assert.NotNull(link.Command);
link.Command(link.CommandParameter!);
Assert.NotNull(vm.OpenResourceCommand.ExecutionTask);
await vm.OpenResourceCommand.ExecutionTask;
var target = Assert.IsType<VideoPageViewModel>(root.NavRoot.NavigationStack.Last().DataContext);
Assert.Equal("https://org.example.test/files/paul/clip.mp4", target.Source.AbsoluteUri);
Assert.Equal("video/mp4", target.MimeType);
}
finally
{
app.DataTemplates.Remove(template);
}
}
[AvaloniaFact]
public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control()
{
var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
Assert.NotNull(page.FindControl<LibVLCSharp.Avalonia.VideoView>("Video"));
}
[Fact]
public async Task Broken_resource_reports_an_error_and_releases_busy_state()
{
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(_ => new(HttpStatusCode.NotFound)));
var vm = CreateViewModel(new BlogResourceService(client));
vm.SelectedPost = Post();
await vm.OpenResourceCommand.ExecuteAsync("absent.mp4");
Assert.False(vm.IsBusy);
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
}
}

View file

@ -0,0 +1,137 @@
using System.Net;
using System.Net.Http.Headers;
using PostIt.Services;
namespace PostIt.Tests;
public class BlogResourceServiceTests
{
private const string Authority = "https://yavsc.pschneider.fr";
[Theory]
[InlineData("IMG-20260923-WA0001.jpg", Authority + "/files/paul/IMG-20260923-WA0001.jpg")]
[InlineData("blogs/42/vidéo.mp4", Authority + "/files/paul/blogs/42/vid%C3%A9o.mp4")]
[InlineData("/files/alice/video.webm", Authority + "/files/alice/video.webm")]
[InlineData(Authority + "/files/paul/photo.jpg", Authority + "/files/paul/photo.jpg")]
[InlineData("https://elsewhere.test/page", "https://elsewhere.test/page")]
[InlineData("//elsewhere.test/page", "https://elsewhere.test/page")]
public void Resolves_links_against_org_user_login_not_blogs_host(string link, string expected)
=> Assert.Equal(expected, BlogResourceService.Resolve(link, Authority + "/connect/", "paul").AbsoluteUri);
[Fact]
public void Encodes_login_and_keeps_trailing_slash()
=> Assert.Equal(Authority + "/files/paul%20test/",
BlogResourceService.GetUserBaseUri(Authority, "paul test").AbsoluteUri);
[Theory]
[InlineData(Authority + "/files/paul/movie.mp4", true)]
[InlineData(Authority + "/files/alice/blogs/42/movie.mp4?download=1", true)]
[InlineData(Authority + "/files/paul/", false)]
[InlineData(Authority + "/files/paul", false)]
[InlineData(Authority + "/files/", false)]
[InlineData(Authority + "/files-other/paul/movie.mp4", false)]
[InlineData(Authority + "/page.mp4", false)]
[InlineData("https://blogs.pschneider.fr/files/paul/movie.mp4", false)]
[InlineData("https://yavsc.pschneider.fr.evil.test/files/paul/movie.mp4", false)]
[InlineData("https://yavsc.pschneider.fr:8443/files/paul/movie.mp4", false)]
[InlineData("http://yavsc.pschneider.fr/files/paul/movie.mp4", false)]
[InlineData("https://user@yavsc.pschneider.fr/files/paul/movie.mp4", false)]
public void Recognizes_only_files_on_the_configured_org_origin(string link, bool expected)
=> Assert.Equal(expected, BlogResourceService.IsUserResource(new Uri(link), Authority));
[Theory]
[InlineData("javascript:alert(1)")]
[InlineData("file:///tmp/video.mp4")]
[InlineData("data:video/mp4;base64,AAAA")]
public void Rejects_non_web_links(string link)
=> Assert.Throws<InvalidOperationException>(() => BlogResourceService.Resolve(link, Authority, "paul"));
[Theory]
[InlineData("video/webm", "movie.jpg", "video/webm")]
[InlineData("image/jpeg", "movie.mp4", "image/jpeg")]
[InlineData("application/octet-stream", "movie.MP4", "video/mp4")]
[InlineData(null, "movie.webm", "video/webm")]
[InlineData(null, "IMG-20260923-WA0001.jpg", "image/jpeg")]
[InlineData(null, "file.unknown", "application/octet-stream")]
public async Task Prefers_server_mime_with_extension_fallback(string? mime, string file, string expected)
{
using var client = new HttpClient(new StubHandler(request =>
{
Assert.Equal(HttpMethod.Head, request.Method);
return Response(HttpStatusCode.OK, mime);
}));
var service = new BlogResourceService(client);
Assert.Equal(expected, await service.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/{file}?v=1"),
TestContext.Current.CancellationToken));
}
[Theory]
[InlineData(HttpStatusCode.MethodNotAllowed)]
[InlineData(HttpStatusCode.NotImplemented)]
public async Task Falls_back_to_a_ranged_get_when_head_is_not_supported(HttpStatusCode status)
{
var calls = 0;
using var client = new HttpClient(new StubHandler(request =>
{
calls++;
if (request.Method == HttpMethod.Head)
return Response(status, null);
Assert.Equal(HttpMethod.Get, request.Method);
Assert.Equal("bytes=0-0", request.Headers.Range?.ToString());
return Response(HttpStatusCode.PartialContent, "video/mp4");
}));
Assert.Equal("video/mp4", await new BlogResourceService(client)
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie"), TestContext.Current.CancellationToken));
Assert.Equal(2, calls);
}
[Theory]
[InlineData(HttpStatusCode.NotFound)]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.InternalServerError)]
public async Task Does_not_disguise_http_errors_as_a_mime_guess(HttpStatusCode status)
{
using var client = new HttpClient(new StubHandler(_ => Response(status, null)));
await Assert.ThrowsAsync<HttpRequestException>(() => new BlogResourceService(client)
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie.mp4"), TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("photo.jpg", Authority + "/files/paul/photo.jpg")]
[InlineData("/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
[InlineData(Authority + "/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
public async Task Inline_images_use_the_same_user_base_and_support_root_relative_paths(string link, string expected)
{
using var client = new HttpClient(new StubHandler(request =>
{
Assert.Equal(expected, request.RequestUri!.AbsoluteUri);
return Response(HttpStatusCode.OK, "image/jpeg");
}));
var resolver = new BlogImageResolver(client) { AssetPathRoot = Authority + "/files/paul/" };
await using var stream = await resolver.ResolveImageResource(link);
Assert.NotNull(stream);
}
[Theory]
[InlineData("file:///etc/passwd")]
[InlineData("data:image/png;base64,AAAA")]
public async Task Inline_images_cannot_read_local_files_or_non_web_schemes(string link)
{
var resolver = new BlogImageResolver { AssetPathRoot = Authority + "/files/paul/" };
await Assert.ThrowsAsync<InvalidOperationException>(() => resolver.ResolveImageResource(link));
}
private static HttpResponseMessage Response(HttpStatusCode status, string? mime)
{
var response = new HttpResponseMessage(status) { Content = new ByteArrayContent([]) };
if (mime is not null)
response.Content.Headers.ContentType = new MediaTypeHeaderValue(mime);
return response;
}
internal sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}
}

View file

@ -42,6 +42,7 @@ public class MainPageSaveTests
var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new BlogsViewModel(blog);
viewModel.NewPostCommand.Execute(null);
var page = new BlogsPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it

View file

@ -55,7 +55,7 @@ public class MarkdownEditorScrollTests
[InlineData(true)]
public void Blog_page_bounds_the_article_viewport(bool preview)
{
var page = new BlogsPage();
var page = new BlogsPage { DataContext = new PostIt.ViewModels.BlogsViewModel { IsEditing = true } };
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
editor.MarkdownText = LongArticle;
editor.IsPreviewVisible = preview;

View file

@ -44,6 +44,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<MainWindow>();
services.AddTransient<MainView>();
services.AddTransient<BlogsPage>();
services.AddTransient<VideoPage>();
// Models

View file

@ -39,6 +39,7 @@
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Markdown.Avalonia.Tight" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" />

View file

@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Markdown.Avalonia.Utils;
namespace PostIt.Services;
public sealed class BlogImageResolver : IPathResolver
{
private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly HttpClient _client;
public BlogImageResolver() : this(Client) { }
public BlogImageResolver(HttpClient client) => _client = client;
public string? AssetPathRoot { get; set; }
public IEnumerable<string>? CallerAssemblyNames { get; set; }
public async Task<Stream?> ResolveImageResource(string relativeOrAbsolutePath)
{
var uri = !relativeOrAbsolutePath.StartsWith("/", StringComparison.Ordinal)
&& Uri.TryCreate(relativeOrAbsolutePath, UriKind.Absolute, out var absolute)
? absolute
: new Uri(new Uri(AssetPathRoot ?? throw new InvalidOperationException(
"La base des fichiers de l'auteur est indisponible.")), relativeOrAbsolutePath);
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
throw new InvalidOperationException("Les images du billet doivent utiliser HTTP ou HTTPS.");
return await _client.GetStreamAsync(uri);
}
}

View file

@ -0,0 +1,98 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Files;
namespace PostIt.Services;
public sealed class BlogResourceService
{
private static readonly HttpClient SharedClient = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly HttpClient _client;
public BlogResourceService() : this(SharedClient) { }
public BlogResourceService(HttpClient client) => _client = client;
public static Uri GetUserBaseUri(string authority, string login)
{
if (string.IsNullOrWhiteSpace(login))
throw new InvalidOperationException("Le login de l'auteur est nécessaire pour résoudre ses fichiers.");
return FileServerUrlHelpers.GetUserFilesUri(GetAuthority(authority), Uri.EscapeDataString(login) + "/");
}
public static Uri Resolve(string link, string authority, string login)
{
if (string.IsNullOrWhiteSpace(link))
throw new ArgumentException("Le lien est vide.", nameof(link));
var origin = GetAuthority(authority);
var uri = link.StartsWith("/", StringComparison.Ordinal)
? new Uri(origin, link)
: Uri.TryCreate(link, UriKind.Absolute, out var absolute)
? absolute
: new Uri(GetUserBaseUri(authority, login), link);
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
throw new InvalidOperationException("Seuls les liens HTTP et HTTPS sont pris en charge.");
return uri;
}
public static bool IsUserResource(Uri uri, string authority)
{
var root = FileServerUrlHelpers.GetUserFilesBaseUri(GetAuthority(authority));
if (!root.IsBaseOf(uri) || !string.IsNullOrEmpty(uri.UserInfo))
return false;
var path = uri.AbsolutePath[root.AbsolutePath.Length..];
var separator = path.IndexOf('/');
return separator > 0 && separator < path.Length - 1;
}
public async Task<string> GetMimeTypeAsync(Uri uri, CancellationToken cancellationToken = default)
{
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
using var response = await _client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented)
{
using var get = new HttpRequestMessage(HttpMethod.Get, uri);
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0);
using var fallback = await _client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
fallback.EnsureSuccessStatusCode();
return GetMimeType(fallback, uri);
}
response.EnsureSuccessStatusCode();
return GetMimeType(response, uri);
}
private static string GetMimeType(HttpResponseMessage response, Uri uri)
{
var mime = response.Content.Headers.ContentType?.MediaType;
if (!string.IsNullOrEmpty(mime) && !mime.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase))
return mime;
return System.IO.Path.GetExtension(uri.AbsolutePath).ToLowerInvariant() switch
{
".mp4" or ".m4v" => "video/mp4",
".webm" => "video/webm",
".ogv" => "video/ogg",
".ogg" => "audio/ogg",
".mov" => "video/quicktime",
".mkv" => "video/x-matroska",
".avi" => "video/x-msvideo",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".pdf" => "application/pdf",
_ => mime ?? "application/octet-stream"
};
}
private static Uri GetAuthority(string authority)
{
if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|| !string.IsNullOrEmpty(uri.UserInfo))
throw new InvalidOperationException("L'URL de Yavsc.Org est invalide dans les paramètres d'authentification.");
return new Uri(uri.GetLeftPart(UriPartial.Authority) + "/");
}
}

View file

@ -42,6 +42,7 @@ public class ViewLocator : IDataTemplate
return data switch
{
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
VideoPageViewModel => services.GetRequiredService<VideoPage>(),
Settings => services.GetRequiredService<SettingsPage>(),
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(),

View file

@ -13,6 +13,8 @@ using Yavsc.Api.Client;
using Yavsc.Abstract.Files;
using Yavsc.Server.Helpers;
using AvaloniaEdit.Document;
using Avalonia.Controls;
using PostIt.Services;
namespace PostIt.ViewModels;
@ -141,8 +143,85 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
public partial ObservableCollection<BlogUploadFile> DraftAttachments { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ReaderArticle))]
[NotifyPropertyChangedFor(nameof(ReaderAuthor))]
public partial BlogPostDto? SelectedPost { get; set; }
[ObservableProperty]
public partial bool IsEditing { get; set; }
[ObservableProperty]
public partial string? ReaderBaseUrl { get; private set; }
public string ReaderArticle => ReaderBaseUrl is not null ? SelectedPost?.Article ?? string.Empty : string.Empty;
public string ReaderAuthor => SelectedPost?.Author?.UserName ?? SelectedPost?.AuthorId ?? string.Empty;
private readonly BlogResourceService _resources = new();
[RelayCommand]
private void NewPost()
{
if (IsDraftModified)
{
this.SetWarningStatus("Enregistrez vos modifications avant de créer un autre billet.");
return;
}
SelectedPost = null;
DraftTitle = string.Empty;
DraftArticle = string.Empty;
DraftAttachments.Clear();
_pendingAttachmentLinks.Clear();
DraftIsPublished = false;
MarkDraftSaved();
IsEditing = true;
}
[RelayCommand]
private void EditPost() => IsEditing = true;
[RelayCommand]
private void ReadPost()
{
IsEditing = false;
UpdateReader();
}
[RelayCommand]
private async Task OpenResourceAsync(string? link)
{
try
{
IsBusy = true;
var uri = BlogResourceService.Resolve(link ?? string.Empty,
Settings.Authentication.Authority, ReaderAuthor);
if (BlogResourceService.IsUserResource(uri, Settings.Authentication.Authority))
{
this.SetInfoStatus("Détection du type de fichier...");
var mime = await _resources.GetMimeTypeAsync(uri);
if (mime.StartsWith("video/", StringComparison.OrdinalIgnoreCase)
&& !OperatingSystem.IsBrowser() && !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS())
{
await ((App)Application.Current!).PushPageAsync(new VideoPageViewModel(uri, mime));
this.SetInfoStatus($"Vidéo : {mime}");
return;
}
this.SetInfoStatus($"Ouverture dans le navigateur ({mime}).");
}
var topLevel = TopLevel.GetTopLevel(((App)Application.Current!).View!)
?? throw new InvalidOperationException("La fenêtre de l'application est indisponible.");
if (!await topLevel.Launcher.LaunchUriAsync(uri))
throw new InvalidOperationException("Le système n'a pas pu ouvrir ce lien.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Impossible d'ouvrir le lien : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[ObservableProperty]
public partial bool IsBusy { get; set; }
@ -258,6 +337,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
}
await RefreshPostsAsync();
UpdateReader();
});
}
@ -503,12 +583,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below.
/// </summary>
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null,
BlogResourceService? resources = null)
{
WindowTitle = "Blog";
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
Services = services;
_resources = resources ?? new BlogResourceService();
Init(settings);
}
@ -523,6 +605,9 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
partial void OnSelectedPostChanged(BlogPostDto? value)
{
UpdateReader();
if (!_preserveDraft)
IsEditing = false;
if (_preserveDraft) return;
// Mirror the selection into the editor buffer so the
// XAML-bound TextBox/TextEditor show the right content
@ -542,6 +627,25 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
UpdateCommandStates();
}
private void UpdateReader()
{
string? baseUrl = null;
try
{
if (SelectedPost is not null)
baseUrl = BlogResourceService.GetUserBaseUri(
Settings.Authentication.Authority, ReaderAuthor).AbsoluteUri;
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or UriFormatException)
{
this.SetErrorStatus($"Impossible de résoudre les ressources du billet : {ex.Message}");
}
ReaderBaseUrl = baseUrl;
OnPropertyChanged(nameof(SelectedPost));
OnPropertyChanged(nameof(ReaderArticle));
OnPropertyChanged(nameof(ReaderAuthor));
}
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
// Save's CanExecute depends on the buffer: the button must

View file

@ -0,0 +1,12 @@
using System;
namespace PostIt.ViewModels;
public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelBase
{
public Uri Source { get; } = source;
public string MimeType { get; } = mimeType;
public string Title => Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
public override bool CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => true; protected set { } }
}

View file

@ -2,13 +2,12 @@
x:Class="PostIt.Views.Blogs.BlogsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:md="using:MarkdownViewer.Core.Controls"
xmlns:reader="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
xmlns:services="using:PostIt.Services"
xmlns:models="using:Yavsc.Blogspot"
xmlns:postitControls="using:PostIt.Controls"
xmlns:views="using:PostIt.Views.Blogs"
xmlns:vm="using:PostIt.ViewModels"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
@ -25,7 +24,7 @@
</Style>
</ContentPage.Styles>
<Grid
RowDefinitions="Auto,Auto,*"
RowDefinitions="Auto,Auto,*,Auto"
Margin="5,5,10,5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
@ -36,23 +35,30 @@
<TextBlock
FontSize="20"
FontWeight="SemiBold"
Text="Blog Posts" />
Text="Billets de blog" />
<TextBox
HorizontalAlignment="Right"
PlaceholderText="Filter by title or author..."
PlaceholderText="Rechercher un titre ou un auteur..."
Text="{Binding SearchText, Mode=TwoWay}" />
</WrapPanel>
<CommandBar x:Name="BlogsCommandBar"
Grid.Row="1"
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
<CommandBar.PrimaryCommands>
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
<CommandBarButton Command="{Binding RefreshCommand}" Label="Actualiser">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="NewPostButton" Command="{Binding NewPostCommand}" Label="Nouveau billet" />
<CommandBarButton x:Name="ReadPostButton" Command="{Binding ReadPostCommand}"
IsVisible="{Binding IsEditing}" Label="Lire" />
<CommandBarButton x:Name="EditPostButton" Command="{Binding EditPostCommand}"
IsEnabled="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}"
IsVisible="{Binding !IsEditing}" Label="Modifier" />
<CommandBarButton
x:Name="SaveButton"
IsVisible="{Binding IsEditing}"
Command="{Binding SaveCommand}"
Classes.modified="{Binding IsDraftModified}"
IsEnabled="{Binding !IsBusy}"
@ -61,13 +67,14 @@
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Command="{Binding DeleteAsync}" Label="Delete">
<CommandBarButton Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="AddAttachmentButton"
IsVisible="{Binding IsEditing}"
Click="AddAttachment_Click"
IsEnabled="{Binding !IsBusy}"
Label="Ajouter fichiers">
@ -77,6 +84,7 @@
</CommandBarButton>
<CommandBarButton
x:Name="ManageAclButton"
IsVisible="{Binding IsEditing}"
Command="{Binding ManageAclAsync}"
Label="ACL">
<CommandBarButton.Icon>
@ -92,6 +100,7 @@
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton
IsVisible="{Binding IsEditing}"
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Label="Publié">
@ -99,13 +108,14 @@
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton IsVisible="{Binding IsEditing}" IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton
x:Name="OpenSignatureDevButton"
IsVisible="{Binding IsEditing}"
Command="{Binding OpenSignatureDevAsync}"
Label="[DEV] Signature">
<CommandBarButton.Icon>
@ -173,7 +183,8 @@
HorizontalAlignment="Stretch"
ResizeDirection="Rows" />
<Grid
<Grid x:Name="PostEditor"
IsVisible="{Binding IsEditing}"
Grid.Row="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
@ -183,7 +194,6 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
@ -226,9 +236,37 @@
</StackPanel>
</Border>
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
</Grid>
<Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}"
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12">
<TextBlock Text="{Binding SelectedPost.Title}" FontSize="28" FontWeight="SemiBold"
TextWrapping="Wrap" />
<WrapPanel Grid.Row="1" Orientation="Horizontal"
IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding ReaderAuthor}" Margin="0,0,16,0" />
<TextBlock Text="{Binding SelectedPost.DateModified, StringFormat='Mis à jour le {0:dd/MM/yyyy HH:mm}'}"
Opacity="0.65" />
</WrapPanel>
<reader:MarkdownScrollViewer x:Name="ArticleReader" Grid.Row="2"
AssetPathRoot="{Binding ReaderBaseUrl}"
Markdown="{Binding ReaderArticle}"
SaveScrollValueWhenContentUpdated="False">
<reader:MarkdownScrollViewer.Plugins>
<reader:MdAvPlugins>
<reader:MdAvPlugins.PathResolver>
<services:BlogImageResolver />
</reader:MdAvPlugins.PathResolver>
</reader:MdAvPlugins>
</reader:MarkdownScrollViewer.Plugins>
<reader:MarkdownScrollViewer.Engine>
<reader:Markdown HyperlinkCommand="{Binding ((vm:BlogsViewModel)DataContext).OpenResourceCommand, ElementName=ArticleReader}" />
</reader:MarkdownScrollViewer.Engine>
</reader:MarkdownScrollViewer>
<TextBlock Grid.RowSpan="3" IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNull}}"
Text="Choisissez un billet dans la liste pour le lire."
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" />
</Grid>
</Grid>
<postitControls:StatusBar Grid.Row="3" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
</Grid>
</ContentPage>

View file

@ -14,15 +14,12 @@ public partial class BlogsPage : ContentPage
InitializeComponent();
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
protected override async void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (DataContext is ViewModels.BlogsViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
await vm.InitializeAsync();
}
}

View file

@ -0,0 +1,19 @@
<ContentPage
x:Class="PostIt.Views.Blogs.VideoPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
xmlns:vm="using:PostIt.ViewModels"
x:DataType="vm:VideoPageViewModel">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
<Border Grid.Row="1" Background="Black">
<vlc:VideoView x:Name="Video" />
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" />
</StackPanel>
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
</Grid>
</ContentPage>

View file

@ -0,0 +1,108 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LibVLCSharp.Shared;
using PostIt.ViewModels;
namespace PostIt.Views.Blogs;
public partial class VideoPage : ContentPage
{
private LibVLC? _libVlc;
private MediaPlayer? _player;
private Media? _media;
public VideoPage() => InitializeComponent();
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
ReleasePlayer();
if (DataContext is not VideoPageViewModel vm)
return;
try
{
Core.Initialize();
_libVlc = new LibVLC();
_player = new MediaPlayer(_libVlc);
_player.EncounteredError += Player_EncounteredError;
_media = new Media(_libVlc, vm.Source);
Video.MediaPlayer = _player;
PlayButton.IsEnabled = true;
PlaybackStatus.Text = vm.MimeType;
if (!_player.Play(_media))
PlaybackStatus.Text = "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur.";
}
catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException
or BadImageFormatException or PlatformNotSupportedException)
{
ReleasePlayer();
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Vérifiez l'installation de LibVLC.";
}
}
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
{
ReleasePlayer();
base.OnNavigatedFrom(e);
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
ReleasePlayer();
base.OnDetachedFromVisualTree(e);
}
private void ReleasePlayer()
{
PlayButton.IsEnabled = false;
Video.MediaPlayer = null;
if (_player is not null)
{
_player.EncounteredError -= Player_EncounteredError;
_player.Stop();
_player.Dispose();
_player = null;
}
_media?.Dispose();
_media = null;
_libVlc?.Dispose();
_libVlc = null;
}
private void Player_EncounteredError(object? sender, EventArgs e)
=> Dispatcher.UIThread.Post(() =>
{
if (ReferenceEquals(sender, _player))
PlaybackStatus.Text = "Impossible de lire cette vidéo. Vous pouvez l'ouvrir dans le navigateur.";
});
private void PlayPause_Click(object? sender, RoutedEventArgs e)
{
if (_player is null || _media is null)
return;
if (_player.IsPlaying)
_player.Pause();
else if (!_player.Play())
PlaybackStatus.Text = "Impossible de reprendre la vidéo.";
}
private async void OpenExternal_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is not VideoPageViewModel vm)
return;
try
{
var topLevel = TopLevel.GetTopLevel(this)
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
PlaybackStatus.Text = "Impossible d'ouvrir le navigateur.";
}
catch (Exception ex)
{
PlaybackStatus.Text = $"Impossible d'ouvrir le navigateur : {ex.Message}";
}
}
}