diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs index fbccb606..68fa514e 100644 --- a/src/PostIt.Tests/BearerScopeTests.cs +++ b/src/PostIt.Tests/BearerScopeTests.cs @@ -8,9 +8,6 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Yavsc.Blogspot; -using Yavsc.Api.Client; -using PostIt.Services; using PostIt.Services; using Xunit; @@ -97,7 +94,7 @@ public class BearerScopeTests // CapturingHttpHandler is the assertion point. It // records the first request's Authorization header and // returns 200 with an empty array (BlogApiClient - // deserialises to List). + // deserialises to List). var captured = new CapturingHttpHandler(); var client = new YavscApiClient( settings, @@ -122,7 +119,7 @@ public class BearerScopeTests // Resolve a BlogApiClient on top. We don't need real // posts; we just need the outbound HTTP request to be // the one we capture. - var blog = new BlogApiClient(subClient, "http://localhost/"); + var blog = new BlogApiClient(subClient); await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken); diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs index 4b541e42..755ce105 100644 --- a/src/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt.Tests/BlogApiTestFakes.cs @@ -1,4 +1,4 @@ -using Yavsc.Blogspot; +using PostIt.Models; using PostIt.Services; using PostIt.ViewModels; using Yavsc.Models; @@ -18,7 +18,7 @@ internal sealed class CallRecorder /// Test fake that records every CallAsync invocation /// and answers them with a canned sequence: the first call gets -/// a server-issued BlogPostDto (Id=42), the second call gets a +/// a server-issued BlogPost (Id=42), the second call gets a /// single-element list containing that post. Used by the ViewModel /// tests and the headless UI test to capture exactly what the /// Save button posts to the server. @@ -44,20 +44,20 @@ internal sealed class RecordingYavscApiClient : YavscApiClient public override Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { _recorder.Calls.Add((method, path, body)); - // BlogPostDto? boxes to BlogPostDto at runtime, so we test the - // non-nullable type — typeof(BlogPostDto?) is a C# error + // BlogPost? boxes to BlogPost at runtime, so we test the + // non-nullable type — typeof(BlogPost?) is a C# error // (CS8639: "typeof cannot be used on a nullable reference // type"). - if (typeof(T) == typeof(BlogPostDto)) - return Task.FromResult((T)(object)new BlogPostDto + if (typeof(T) == typeof(BlogPost)) + return Task.FromResult((T)(object)new BlogPost { Id = 42, Title = "Mon premier billet", AuthorId = "tester", Article = "Contenu du billet de test.", }); - if (typeof(T) == typeof(List)) - return Task.FromResult((T)(object)new List + if (typeof(T) == typeof(List)) + return Task.FromResult((T)(object)new List { new() { Id = 42, Title = "Mon premier billet" } }); diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs index b6bf963a..c76115d7 100644 --- a/src/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt.Tests/MainPageSaveTests.cs @@ -2,8 +2,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; -using Yavsc.Blogspot; -using Yavsc.Api.Client; +using PostIt.Models; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; @@ -25,7 +24,7 @@ namespace PostIt.Tests; /// in which a brand-new post can be created), the binding has /// no target and the user's keystrokes are silently dropped. /// Clicking "Save" then routes to the VM branch -/// if (SelectedPost is null) { new BlogPostDto { Title = string.Empty, ... } } +/// if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } } /// which the controller rejects with 400 "The Title field is /// required." This test fails on that branch today and will /// pass once the VM owns a dedicated Title/Article @@ -41,7 +40,7 @@ public class MainPageSaveTests // not a Control, so it needs a navigation host). var recorder = new CallRecorder(); var api = new RecordingYavscApiClient(recorder); - var blog = new BlogApiClient(api, "http://localhost/"); + var blog = new BlogApiClient(api); var viewModel = new MainPageViewModel(blog); var page = new MainPage { DataContext = viewModel }; @@ -77,14 +76,14 @@ public class MainPageSaveTests // we inspect the recorder. await Task.Delay(200); - // Assert: the first POST to "blog" carried a BlogPostDto + // Assert: the first POST to "blog" carried a BlogPost // whose Title is exactly what the user typed. The bug // fails this assertion with Title == string.Empty. Assert.NotEmpty(recorder.Calls); var (method, path, body) = recorder.FirstCall; Assert.Equal(HttpMethod.Post, method); Assert.Equal("blog", path); - var sent = Assert.IsType(body); + var sent = Assert.IsType(body); Assert.Equal(typed, sent.Title); } } diff --git a/src/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt.Tests/PostItViewModelTests.cs index 2dee4604..48569915 100644 --- a/src/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt.Tests/PostItViewModelTests.cs @@ -1,5 +1,4 @@ -using Yavsc.Blogspot; -using Yavsc.Api.Client; +using PostIt.Models; using PostIt.Services; using PostIt.ViewModels; @@ -15,12 +14,12 @@ public class PostItViewModelTests // default; tests construct one with a fake YavscApiClient that // throws on any call (we never call the API in this test). var fakeApi = new ThrowingYavscApiClient(); - var blog = new BlogApiClient(fakeApi, "http://localhost/"); + var blog = new BlogApiClient(fakeApi); var viewModel = new MainPageViewModel(blog); - viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); - viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); - viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" }); + viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); + viewModel.Posts.Add(new BlogPost { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); + viewModel.Posts.Add(new BlogPost { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" }); viewModel.SearchText = "search"; viewModel.SearchCommand.Execute(null); @@ -41,13 +40,13 @@ public class PostItViewModelTests // The new BlogApiClient delegates transport to YavscApiClient. // We feed it a fake YavscApiClient that returns the expected // list straight from CallAsync. - var expected = new List + var expected = new List { new() { Id = 1, Title = "Hello" }, new() { Id = 2, Title = "World" } }; var api = new StubYavscApiClient(expected); - var blog = new BlogApiClient(api, "http://localhost/"); + var blog = new BlogApiClient(api); var posts = await blog.GetPostsAsync(); @@ -77,8 +76,8 @@ public class PostItViewModelTests /// Test fake that hands back a canned list of posts from any CallAsync. private sealed class StubYavscApiClient : YavscApiClient { - private readonly List _posts; - public StubYavscApiClient(List posts) + private readonly List _posts; + public StubYavscApiClient(List posts) : base( new Settings { @@ -98,7 +97,7 @@ public class PostItViewModelTests { // The canned fake only knows about a list of posts; the // BlogApiClient test asserts on that list directly. - if (typeof(T) == typeof(List)) + if (typeof(T) == typeof(List)) return Task.FromResult((T)(object)_posts); return Task.FromResult(default(T)!); } diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs index e54bc541..c020fec9 100644 --- a/src/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt.Tests/YavscApiClientTests.cs @@ -8,9 +8,6 @@ using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Threading; -using Yavsc.Blogspot; -using Yavsc.Api.Client; -using PostIt.Services; using System.Threading.Tasks; using IdentityModel.OidcClient; using IdentityModel.OidcClient.Browser; diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index c5ab68e2..b5740f2f 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -7,7 +7,6 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Styling; using PostIt.Services; -using Yavsc.Api.Client; using PostIt.ViewModels; using PostIt.Views; @@ -56,11 +55,7 @@ public partial class App : Application "PostIt", "tokens.json")); var api = new YavscApiClient(settings, tokenStore); - var client = new BlogApiClient(api, settings.BlogsApiUrl); - var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); - var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); - var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); - var contactService = new ContactService(userSearchClient); + var client = new BlogApiClient(api); var services = new ServiceCollection(); @@ -80,21 +75,14 @@ public partial class App : Application services.AddSingleton(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); // ViewModels services.AddSingleton(settings); - services.AddSingleton(api); - services.AddSingleton(api); + services.AddSingleton(api); services.AddSingleton(client); - services.AddSingleton(circleClient); - services.AddSingleton(blogAclClient); - services.AddSingleton(userSearchClient); - services.AddSingleton(contactService); services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); // Persistent session banner: one instance for the lifetime of // the app so the same VM survives page navigation. diff --git a/src/Yavsc.Abstract/Blogspot/BlogPost.cs b/src/PostIt/PostIt/Models/BlogPost.cs similarity index 62% rename from src/Yavsc.Abstract/Blogspot/BlogPost.cs rename to src/PostIt/PostIt/Models/BlogPost.cs index 5e397245..e62fcea2 100644 --- a/src/Yavsc.Abstract/Blogspot/BlogPost.cs +++ b/src/PostIt/PostIt/Models/BlogPost.cs @@ -1,10 +1,11 @@ using System; using Yavsc.Abstract.Identity; using Yavsc.Abstract.Identity.Security; +using Yavsc.Blogspot; -namespace Yavsc.Blogspot; +namespace PostIt.Models; -public class BlogPostDto : IBlogPost +public class BlogPost : IBlogPost { public string AuthorId { get; set; } @@ -12,12 +13,12 @@ public class BlogPostDto : IBlogPost public string Article { get; set ; } public string Photo { get; set ; } - public long Id { get; set; } - public DateTime DateCreated { get; set; } - public string UserCreated { get; set; } - public DateTime DateModified { get; set; } - public string UserModified { get; set; } - public string Title { get; set; } + public long Id { get; set ; } + public DateTime DateCreated { get; set ; } + public string UserCreated { get; set ; } + public DateTime DateModified { get; set ; } + public string UserModified { get; set ; } + public string Title { get; set ; } public bool AuthorizeCircle(long circleId) { diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index e4d51a88..1b14653a 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -3,11 +3,13 @@ net10.0 enable latest + true true 1.0.1.0 1.0.1.0 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f 1.0.1-5 + @@ -24,8 +26,8 @@ + - @@ -40,4 +42,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Api.Client/BlogApiClient.cs b/src/PostIt/PostIt/Services/BlogApiClient.cs similarity index 59% rename from src/Yavsc.Api.Client/BlogApiClient.cs rename to src/PostIt/PostIt/Services/BlogApiClient.cs index 611c892f..5e927b97 100644 --- a/src/Yavsc.Api.Client/BlogApiClient.cs +++ b/src/PostIt/PostIt/Services/BlogApiClient.cs @@ -3,18 +3,17 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Yavsc.Blogspot; +using PostIt.Models; -namespace Yavsc.Api.Client; +namespace PostIt.Services; /// /// High-level client for the Blog subsystem of the Yavsc API /// (deployed at https://blogs.pschneider.fr). All transport /// concerns — base URL, JSON serialisation, Bearer auth, silent /// refresh on 401, request body shaping — are delegated to -/// , which lives in the consuming -/// application (PostIt). This class is a thin DTO↔path mapper, -/// nothing more. +/// . This class is a thin DTO↔path +/// mapper, nothing more. /// /// URL convention. 's /// BaseAddress already terminates with /api/v1/ @@ -35,37 +34,33 @@ public sealed class BlogApiClient { private const string DefaultPathPrefix = "blog"; - private readonly IYavscApiClient _api; - private readonly Uri _baseAddress; + private readonly YavscApiClient _api; private readonly string _pathPrefix; - public BlogApiClient(IYavscApiClient api, string blogsBaseAddress, string pathPrefix = DefaultPathPrefix) + public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix) { _api = api ?? throw new ArgumentNullException(nameof(api)); - if (string.IsNullOrEmpty(blogsBaseAddress)) - throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress)); - // e.g. "https://blogs.pschneider.fr/api/v1/" — keep the + // ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the // trailing slash so relative paths ("posts") resolve correctly. - _baseAddress = new Uri(blogsBaseAddress); - api.Http.BaseAddress = _baseAddress; + api.Http.BaseAddress = new Uri(api.Settings.BlogsApiUrl); _pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix; } - public Task> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default) - => _api.CallAsync>( + public Task> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default) + => _api.CallAsync>( HttpMethod.Get, $"{_pathPrefix}?start={start}&take={take}", ct: ct); - public Task GetPostAsync(long id, CancellationToken ct = default) - => _api.CallAsync(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct); + public Task GetPostAsync(long id, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct); - public Task CreatePostAsync(BlogPostDto post, CancellationToken ct = default) - => _api.CallAsync(HttpMethod.Post, _pathPrefix, body: post, ct: ct); + public Task CreatePostAsync(BlogPost post, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Post, _pathPrefix, body: post, ct: ct); - public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default) + public Task UpdatePostAsync(long id, BlogPost post, CancellationToken ct = default) => _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct); public Task DeletePostAsync(long id, CancellationToken ct = default) diff --git a/src/PostIt/PostIt/Services/ContactService.Desktop.cs b/src/PostIt/PostIt/Services/ContactService.Desktop.cs index 9da4a685..82746c49 100644 --- a/src/PostIt/PostIt/Services/ContactService.Desktop.cs +++ b/src/PostIt/PostIt/Services/ContactService.Desktop.cs @@ -1,72 +1,27 @@ #if !ANDROID && !IOS using System; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; using System.Threading; using System.Threading.Tasks; -using Yavsc.Api.Client; -using Yavsc.Api.Client.Dtos; namespace PostIt.Services; /// -/// Desktop implementation of backed -/// by the central /api/user-search endpoint -/// (). +/// Desktop stub for IContactService. /// -/// Desktop has no equivalent of the mobile address book -/// (no Contacts.Default, no CardDAV out of the box), so the -/// address book is built on demand from the Yavsc user table. -/// Results are accumulated in an in-memory cache exposed as -/// ; the cache is process-lifetime only -/// — there's no persistence layer. +/// On desktop targets (Linux, macOS, Windows) MAUI Essentials +/// Contacts.Default throws NotImplementedInReferenceAssemblyException, +/// so we short-circuit with an empty list rather than trying to +/// call into the portable facade at runtime. /// -/// This is the consumer that closes the loop with the -/// user-search endpoint landed on the server in commit 6 -/// (b3056f1c) and the client in commit 7 -/// (6e7e0414). +/// Future provider plug-ins (Google Contacts API, Exchange EWS, +/// CardDAV) can either replace this stub on a per-OS basis or +/// live behind their own IContactService implementation that the +/// DI container selects by configuration. /// public sealed class ContactService : IContactService { - private readonly UserSearchClient _client; - - public ObservableCollection Contacts { get; } = new(); - - public ContactService(UserSearchClient client) - { - _client = client ?? throw new ArgumentNullException(nameof(client)); - } - public Task> GetDeviceContactsAsync(CancellationToken ct = default) - => Task.FromResult>(Contacts.ToArray()); - - public async Task SearchAsync(string query, CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(query)) - { - // Clear the cache to mirror an empty result. The - // address-book UX treats an empty query as "start - // over". - Contacts.Clear(); - return; - } - - var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); - if (results is null) return; - - // Append the search results to the cache. We don't - // de-dupe across searches — the simplest behaviour, and - // matches what users expect from a search panel ("show - // me what came back"). Callers wanting a single list - // can re-render Contacts on the next query. - foreach (var u in results) - { - Contacts.Add(new ContactDto( - Id: u.Id, - DisplayName: u.FullName ?? u.UserName, - Email: u.Email)); - } - } + => Task.FromResult>(Array.Empty()); } -#endif \ No newline at end of file +#endif diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs index d3eb8a10..744fb9a6 100644 --- a/src/PostIt/PostIt/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -1,7 +1,6 @@ #if ANDROID || IOS using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel.Communication; @@ -25,8 +24,6 @@ namespace PostIt.Services; /// public sealed class ContactService : IContactService { - public ObservableCollection Contacts { get; } = new(); - public async Task> GetDeviceContactsAsync(CancellationToken ct = default) { if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) @@ -41,18 +38,21 @@ public sealed class ContactService : IContactService var contacts = await Contacts.Default.GetAllAsync(); if (contacts is null) return Array.Empty(); - // Flatten the per-contact email list down to one - // primary email. The platform-neutral ContactDto only - // carries one; the use case ("invite / add to a - // circle") only needs one. The first non-empty entry - // wins. - Contacts.Clear(); + var result = new List(); foreach (var c in contacts) { - var email = FlattenPrimaryEmail(c.Emails); - Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email)); + var emails = new List(); + if (c.Emails is not null) + { + foreach (var e in c.Emails) + { + if (!string.IsNullOrEmpty(e.EmailAddress)) + emails.Add(e.EmailAddress); + } + } + result.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, emails)); } - return Contacts.ToArray(); + return result; } catch (Exception ex) { @@ -60,22 +60,5 @@ public sealed class ContactService : IContactService return Array.Empty(); } } - - public Task SearchAsync(string query, CancellationToken ct = default) - => throw new PlatformNotSupportedException( - "SearchAsync is not supported on mobile — use GetDeviceContactsAsync " + - "to load the local address book. The network search lives on the " + - "desktop service, which queries the central user-search endpoint."); - - private static string? FlattenPrimaryEmail(IEnumerable? emails) - { - if (emails is null) return null; - foreach (var e in emails) - { - if (!string.IsNullOrEmpty(e.EmailAddress)) - return e.EmailAddress; - } - return null; - } } -#endif \ No newline at end of file +#endif diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs index 4f0ba102..8c6da44a 100644 --- a/src/PostIt/PostIt/Services/IContactService.cs +++ b/src/PostIt/PostIt/Services/IContactService.cs @@ -1,14 +1,13 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; namespace PostIt.Services; /// -/// Abstraction over device contact providers (MAUI Essentials on -/// mobile, the central /api/user-search endpoint on desktop). +/// Abstraction over device contact providers (MAUI Essentials on mobile, +/// future Google/Exchange/IMAP providers). /// /// Implementations live next to this file in platform-conditional /// source files: ContactService.Mobile.cs (ANDROID/IOS) and @@ -16,46 +15,15 @@ namespace PostIt.Services; /// public interface IContactService { - /// - /// Returns the contacts known so far. On mobile this is the - /// full device address book (after permission grant); on - /// desktop this is the in-memory cache populated by previous - /// calls — empty until the user - /// has searched for something. - /// Task> GetDeviceContactsAsync(CancellationToken ct = default); - - /// - /// On desktop: hits GET /api/user-search?q=… and - /// appends matching users to the in-memory cache exposed via - /// . On mobile: throws - /// — the mobile - /// provider uses the device-local address book, not a - /// network search. - /// - Task SearchAsync(string query, CancellationToken ct = default); - - /// - /// Live view of the in-memory contact cache. UI binds to - /// this directly for a \"search results\" panel; on mobile - /// implementations this is populated eagerly by - /// . - /// - ObservableCollection Contacts { get; } } /// -/// Platform-neutral contact DTO. Source-of-truth shape for the UI -/// layer; concrete providers (MAUI Essentials on mobile, -/// UserSearchClient on desktop) map to this type. -/// -/// Email is a single string on purpose: the central -/// search endpoint returns one email per user, and the UI use -/// case is \"pick someone to invite / add to a circle\", which -/// never needs more than one. Multi-email contacts on mobile -/// flatten to the primary address (first non-empty). +/// Platform-neutral contact DTO. Source-of-truth shape for the UI layer; +/// concrete providers (MAUI Essentials today, Google Contacts API later) +/// map to this type. /// public sealed record ContactDto( string Id, string DisplayName, - string? Email); \ No newline at end of file + IReadOnlyList Emails); diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index b611fe02..9ae1453b 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -9,7 +9,6 @@ using System.Threading; using System.Threading.Tasks; using IdentityModel.OidcClient; using PostIt.ViewModels; -using Yavsc.Api.Client; namespace PostIt.Services; @@ -25,7 +24,7 @@ namespace PostIt.Services; /// only refreshes once even if many /// concurrent requests are in flight. /// -public class YavscApiClient : IYavscApiClient, IAsyncDisposable +public class YavscApiClient : IAsyncDisposable { // 60s of slack before the access_token's nominal expiry. Covers // network latency + JWT validation on the server side. diff --git a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs deleted file mode 100644 index 17d4c4be..00000000 --- a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Yavsc.Api.Client; -using Yavsc.Api.Client.Dtos; - -namespace PostIt.ViewModels; - -/// -/// View model for the "Mes cercles" page. CRUD on the caller's own -/// circles (the server scopes every endpoint to the caller's uid -/// since the BlogAcl fix on this branch). -/// -/// The view lists circles in , supports -/// create / edit via , and exposes -/// per-item Delete and per-item edit commands. -/// drives a progress overlay during API calls; -/// surfaces success / error feedback in the view footer. -/// -public partial class CirclesPageViewModel : ViewModelBase -{ - private readonly CircleApiClient _client; - - [ObservableProperty] - public partial ObservableCollection Circles { get; set; } = new(); - - [ObservableProperty] - public partial CircleDto? SelectedCircle { get; set; } - - /// Editor buffer for the new / edited circle's name. - [ObservableProperty] - public partial string DraftName { get; set; } = string.Empty; - - /// Editor buffer for the new / edited circle's visibility flag. - [ObservableProperty] - public partial bool DraftPublic { get; set; } - - [ObservableProperty] - public partial bool IsBusy { get; set; } - - [ObservableProperty] - public partial string StatusMessage { get; set; } = string.Empty; - - public CirclesPageViewModel(CircleApiClient client) - { - _client = client ?? throw new ArgumentNullException(nameof(client)); - } - - public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - - [RelayCommand] - public async Task RefreshAsync() - { - IsBusy = true; - try - { - var list = await _client.GetMyCirclesAsync(); - Circles = new ObservableCollection(list ?? new()); - StatusMessage = $"{Circles.Count} cercle(s)"; - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - public void StartCreate() - { - SelectedCircle = null; - DraftName = string.Empty; - DraftPublic = false; - StatusMessage = "Nouveau cercle"; - } - - [RelayCommand] - public void StartEdit(CircleDto? circle) - { - if (circle is null) return; - SelectedCircle = circle; - DraftName = circle.Name; - DraftPublic = circle.Public; - StatusMessage = $"Édition de « {circle.Name} »"; - } - - [RelayCommand] - public async Task SaveAsync() - { - if (string.IsNullOrWhiteSpace(DraftName)) - { - StatusMessage = "Le nom est obligatoire"; - return; - } - - IsBusy = true; - try - { - if (SelectedCircle is null) - { - var created = await _client.CreateCircleAsync(new CircleDto - { - Name = DraftName.Trim(), - Public = DraftPublic, - }); - StatusMessage = created is null - ? "Création échouée" - : $"Cercle « {created.Name} » créé"; - } - else - { - SelectedCircle.Name = DraftName.Trim(); - SelectedCircle.Public = DraftPublic; - await _client.UpdateCircleAsync(SelectedCircle.Id, SelectedCircle); - StatusMessage = $"Cercle « {SelectedCircle.Name} » mis à jour"; - } - await RefreshAsync(); - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - public async Task DeleteAsync(CircleDto? circle) - { - if (circle is null) return; - IsBusy = true; - try - { - await _client.DeleteCircleAsync(circle.Id); - StatusMessage = $"Cercle « {circle.Name} » supprimé"; - await RefreshAsync(); - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } -} diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index d907606f..e7ea26a0 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -4,8 +4,7 @@ using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Yavsc.Blogspot; -using Yavsc.Api.Client; +using PostIt.Models; using PostIt.Services; namespace PostIt.ViewModels; @@ -25,7 +24,7 @@ public partial class MainPageViewModel : ViewModelBase /// previous "{Binding SelectedPost.Title}" binding, the user's /// keystrokes were silently dropped whenever /// SelectedPost was null, which made the editor a trap - /// and caused Save to POST a BlogPostDto with an empty + /// and caused Save to POST a BlogPost with an empty /// title — hence the 400 "The Title field is required". [ObservableProperty] public partial string DraftTitle { get; set; } @@ -47,13 +46,13 @@ public partial class MainPageViewModel : ViewModelBase public partial string SearchText { get; set; } [ObservableProperty] - public partial ObservableCollection Posts { get; set; } + public partial ObservableCollection Posts { get; set; } [ObservableProperty] - public partial ObservableCollection FilteredPosts { get; set; } + public partial ObservableCollection FilteredPosts { get; set; } [ObservableProperty] - public partial BlogPostDto? SelectedPost { get; set; } + public partial BlogPost? SelectedPost { get; set; } [ObservableProperty] public partial bool IsBusy { get; set; } @@ -83,8 +82,8 @@ public partial class MainPageViewModel : ViewModelBase private void Init(Settings? settings) { SearchText = string.Empty; - Posts = new ObservableCollection(); - FilteredPosts = new ObservableCollection(); + Posts = new ObservableCollection(); + FilteredPosts = new ObservableCollection(); SelectedPost = null; IsBusy = false; StatusMessage = "Ready"; @@ -120,7 +119,7 @@ public partial class MainPageViewModel : ViewModelBase partial void OnSearchTextChanged(string value) => ApplyFilter(); - partial void OnSelectedPostChanged(BlogPostDto? value) + partial void OnSelectedPostChanged(BlogPost? value) { // Mirror the selection into the editor buffer so the // XAML-bound TextBox/TextEditor show the right content @@ -177,7 +176,7 @@ public partial class MainPageViewModel : ViewModelBase await ExecuteAsync(async () => { - // Build a fresh BlogPostDto from the editor buffer on + // Build a fresh BlogPost from the editor buffer on // every Save — we no longer mutate SelectedPost in // place. The previous behaviour copied the buffer // (which was a no-op when SelectedPost was null) @@ -189,7 +188,7 @@ public partial class MainPageViewModel : ViewModelBase // the update path. if (SelectedPost is null || SelectedPost.Id == 0) { - var draft = new BlogPostDto + var draft = new BlogPost { Title = DraftTitle, Article = DraftArticle ?? string.Empty, @@ -205,7 +204,7 @@ public partial class MainPageViewModel : ViewModelBase } else { - var update = new BlogPostDto + var update = new BlogPost { Id = SelectedPost.Id, AuthorId = SelectedPost.AuthorId, @@ -317,32 +316,4 @@ public partial class MainPageViewModel : ViewModelBase /// forced the buggy "draft with empty title" branch. private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - - /// - /// Raised when the user asks to open the "manage ACL" dialog for - /// the currently selected post. The MainPage code-behind - /// listens to this event and pushes a PostAclDialog on the - /// navigation stack. The VM itself can't navigate directly - /// because the navigation surface (NavigationPage) lives - /// in the View layer. - /// - public event EventHandler? ManageAclRequested; - - [RelayCommand(CanExecute = nameof(CanManageAcl))] - public void ManageAcl() - { - if (SelectedPost is null) return; - ManageAclRequested?.Invoke(this, SelectedPost); - } - - /// - /// Raised when the user asks to open the circles page (full - /// CRUD on their own circles). Same routing as - /// . - /// - public event EventHandler? OpenCirclesRequested; - - [RelayCommand] - public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty); } diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs deleted file mode 100644 index 68b96b7c..00000000 --- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Yavsc.Blogspot; -using Yavsc.Api.Client; -using Yavsc.Api.Client.Dtos; - -namespace PostIt.ViewModels; - -/// -/// View model for the "Gérer l'ACL" modal of a single blog post. -/// -/// Loads the caller's circles once on construct (the dropdown -/// only shows circles the user owns), then keeps an in-memory list -/// of the ACL entries for the post. / -/// are the only mutating verbs; both -/// refresh the list afterwards so the UI stays in sync with the -/// server. -/// -/// The server is the source of truth: it scopes every -/// endpoint to the caller's uid and rejects ACL grants on posts -/// the caller doesn't own. This VM does not re-validate that — -/// any 403 / 404 will surface as an exception caught by the -/// command and routed to . -/// -public partial class PostAclDialogViewModel : ViewModelBase -{ - private readonly BlogAclApiClient _aclClient; - private readonly CircleApiClient _circleClient; - - /// The post whose ACL is being edited. Set by the - /// caller (MainPage) when opening the dialog. - public BlogPostDto Post { get; } - - [ObservableProperty] - public partial ObservableCollection MyCircles { get; set; } = new(); - - [ObservableProperty] - public partial ObservableCollection AclEntries { get; set; } = new(); - - [ObservableProperty] - public partial CircleDto? SelectedCircleToAdd { get; set; } - - [ObservableProperty] - public partial bool IsBusy { get; set; } - - [ObservableProperty] - public partial string StatusMessage { get; set; } = string.Empty; - - public PostAclDialogViewModel( - BlogPostDto post, - BlogAclApiClient aclClient, - CircleApiClient circleClient) - { - Post = post ?? throw new ArgumentNullException(nameof(post)); - _aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient)); - _circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient)); - } - - public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - - [RelayCommand] - public async Task LoadAsync() - { - IsBusy = true; - try - { - // Load circles and ACL entries in parallel — both are - // independent reads on the same host. The caller's uid - // is implicit in both endpoints. - var circlesTask = _circleClient.GetMyCirclesAsync(); - var aclTask = _aclClient.GetMyAclAsync(); - await Task.WhenAll(circlesTask, aclTask); - - var circles = circlesTask.Result ?? new List(); - MyCircles = new ObservableCollection(circles); - - var allAcl = aclTask.Result ?? new List(); - AclEntries = new ObservableCollection( - allAcl.Where(a => a.BlogPostId == Post.Id)); - - StatusMessage = $"{AclEntries.Count} autorisation(s)"; - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - public async Task AddAsync() - { - if (SelectedCircleToAdd is null) - { - StatusMessage = "Sélectionnez un cercle à ajouter"; - return; - } - - IsBusy = true; - try - { - var created = await _aclClient.GrantAsync(new CircleAuthorizationDto - { - CircleId = SelectedCircleToAdd.Id, - BlogPostId = Post.Id, - Comment = false, - }); - if (created is not null) - { - AclEntries.Add(created); - StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé"; - } - else - { - StatusMessage = "Autorisation refusée par le serveur"; - } - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - public async Task RevokeAsync(CircleAuthorizationDto? acl) - { - if (acl is null) return; - IsBusy = true; - try - { - await _aclClient.RevokeAsync(acl.CircleId); - AclEntries.Remove(acl); - StatusMessage = "Autorisation révoquée"; - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } -} diff --git a/src/PostIt/PostIt/Views/CirclesPage.axaml b/src/PostIt/PostIt/Views/CirclesPage.axaml deleted file mode 100644 index d9320eb2..00000000 --- a/src/PostIt/PostIt/Views/CirclesPage.axaml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - -