Display notifications on PostIt home page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
8b4b6a7c53
commit
fae754f102
10 changed files with 402 additions and 6 deletions
|
|
@ -7,6 +7,10 @@
|
||||||
* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me`
|
* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me`
|
||||||
pour exposer les notifications publiques et ciblées à PostIt sur la
|
pour exposer les notifications publiques et ciblées à PostIt sur la
|
||||||
Home page ; les notifications d'administration restent filtrées.
|
Home page ; les notifications d'administration restent filtrées.
|
||||||
|
* [PostIt] Affichage des notifications sur la page d'accueil, avec
|
||||||
|
actualisation, indicateur de chargement et message d'erreur. Les
|
||||||
|
notifications publiques sont accessibles sans connexion et la liste
|
||||||
|
est rechargée lors d'un changement de session.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|
|
||||||
199
src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs
Normal file
199
src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
using System.Net.Http;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using PostIt.Services;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
using PostIt.Views;
|
||||||
|
using Yavsc.Abstract.Models.Messaging;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
public class HomePageNotificationsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_loads_public_notifications_from_the_business_host()
|
||||||
|
{
|
||||||
|
var transport = new NotificationTransport();
|
||||||
|
var vm = CreateViewModel(transport);
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(1, "Information") });
|
||||||
|
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
|
||||||
|
Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath);
|
||||||
|
Assert.True(transport.WasAnonymous);
|
||||||
|
Assert.Equal("Information", Assert.Single(vm.Notifications).title);
|
||||||
|
Assert.False(vm.IsLoadingNotifications);
|
||||||
|
Assert.False(vm.HasNoNotifications);
|
||||||
|
Assert.Null(vm.NotificationsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed()
|
||||||
|
{
|
||||||
|
var transport = new NotificationTransport();
|
||||||
|
var vm = CreateViewModel(transport);
|
||||||
|
vm.Notifications.Add(Note(1, "Old"));
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
|
||||||
|
Assert.Empty(vm.Notifications);
|
||||||
|
Assert.True(vm.HasNoNotifications);
|
||||||
|
|
||||||
|
transport.Response = Task.FromException<List<Notification>>(new HttpRequestException("Server unavailable"));
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
|
||||||
|
Assert.Empty(vm.Notifications);
|
||||||
|
Assert.False(vm.HasNoNotifications);
|
||||||
|
Assert.Contains("Server unavailable", vm.NotificationsError);
|
||||||
|
Assert.False(vm.IsLoadingNotifications);
|
||||||
|
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Recovered") });
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
Assert.Null(vm.NotificationsError);
|
||||||
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_ignores_an_older_response_after_a_newer_refresh()
|
||||||
|
{
|
||||||
|
var transport = new NotificationTransport();
|
||||||
|
var vm = CreateViewModel(transport);
|
||||||
|
var oldResponse = new TaskCompletionSource<List<Notification>>();
|
||||||
|
transport.Response = oldResponse.Task;
|
||||||
|
var oldLoad = vm.RefreshNotificationsAsync();
|
||||||
|
Assert.True(vm.IsLoadingNotifications);
|
||||||
|
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Current") });
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
oldResponse.SetResult(new List<Notification> { Note(1, "Stale") });
|
||||||
|
await oldLoad;
|
||||||
|
|
||||||
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
Assert.False(vm.IsLoadingNotifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data()
|
||||||
|
{
|
||||||
|
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json");
|
||||||
|
var settings = new Settings();
|
||||||
|
var store = new TokenStore(tokenPath);
|
||||||
|
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
|
||||||
|
DateTimeOffset.UtcNow.AddHours(1), null));
|
||||||
|
await using var api = new YavscApiClient(settings, store);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var session = new SessionStatusViewModel { Api = api };
|
||||||
|
var transport = new NotificationTransport();
|
||||||
|
var vm = CreateViewModel(transport, session);
|
||||||
|
session.Refresh();
|
||||||
|
Assert.False(transport.WasAnonymous);
|
||||||
|
Assert.EndsWith("/notifications/me", transport.LastPath);
|
||||||
|
var privateResponse = new TaskCompletionSource<List<Notification>>();
|
||||||
|
transport.Response = privateResponse.Task;
|
||||||
|
var privateLoad = vm.RefreshNotificationsAsync();
|
||||||
|
Assert.False(transport.WasAnonymous);
|
||||||
|
Assert.EndsWith("/notifications/me", transport.LastPath);
|
||||||
|
|
||||||
|
vm.Notifications.Add(Note(9, "Private"));
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Public") });
|
||||||
|
session.SetError("Disconnected");
|
||||||
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
Assert.True(transport.WasAnonymous);
|
||||||
|
|
||||||
|
privateResponse.SetResult(new List<Notification> { Note(1, "Late private data") });
|
||||||
|
await privateLoad;
|
||||||
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(tokenPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
|
||||||
|
{
|
||||||
|
var transport = new NotificationTransport
|
||||||
|
{
|
||||||
|
Response = Task.FromResult(new List<Notification> { Note(1, "Notification title") })
|
||||||
|
};
|
||||||
|
var vm = CreateViewModel(transport);
|
||||||
|
var page = new HomePage { DataContext = vm };
|
||||||
|
var nav = new NavigationPage();
|
||||||
|
var window = new Window { Content = nav, Width = 320, Height = 450 };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
await nav.PushAsync(page);
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
Assert.Single(vm.Notifications);
|
||||||
|
var texts = page.GetVisualDescendants().OfType<TextBlock>().ToArray();
|
||||||
|
Assert.Contains(texts, t => t.Text == "Notification title"
|
||||||
|
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
|
||||||
|
Assert.Contains(texts, t => t.Text == "Notification body"
|
||||||
|
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
|
||||||
|
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
|
||||||
|
b => b.Command == vm.RefreshNotificationsCommand);
|
||||||
|
Assert.True(refresh.IsEnabled);
|
||||||
|
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
|
||||||
|
s => s.Extent.Height > s.Viewport.Height);
|
||||||
|
|
||||||
|
await nav.PushAsync(new ContentPage());
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
|
||||||
|
await nav.PopAsync();
|
||||||
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HomePageViewModel CreateViewModel(NotificationTransport transport,
|
||||||
|
SessionStatusViewModel? session = null)
|
||||||
|
=> new(null!, new Settings(), session ?? new SessionStatusViewModel(),
|
||||||
|
new NotificationsApiClient(transport, "https://business.example/api/v1/"));
|
||||||
|
|
||||||
|
private static Notification Note(long id, string title)
|
||||||
|
=> new() { Id = id, title = title, body = "Notification body", click_action = "Close" };
|
||||||
|
|
||||||
|
private sealed class NotificationTransport : IYavscApiClient
|
||||||
|
{
|
||||||
|
public HttpClient Http { get; } = new();
|
||||||
|
public string? LastPath { get; private set; }
|
||||||
|
public bool WasAnonymous { get; private set; }
|
||||||
|
public Task<List<Notification>> Response { get; set; } = Task.FromResult(new List<Notification>());
|
||||||
|
|
||||||
|
public Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
|
||||||
|
=> ReadAsync<T>(path, true);
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpMethod.Get, method);
|
||||||
|
return ReadAsync<T>(path, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ReadAsync<T>(string path, bool anonymous)
|
||||||
|
{
|
||||||
|
LastPath = path;
|
||||||
|
WasAnonymous = anonymous;
|
||||||
|
return (T)(object)await Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory,
|
||||||
|
CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task CallAsync(HttpMethod method, string path, object? body = null,
|
||||||
|
CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory,
|
||||||
|
CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Http.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,56 @@ namespace PostIt.Tests;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class YavscApiClientTests
|
public class YavscApiClientTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
|
||||||
|
{
|
||||||
|
using var apiServer = new StubApiServer(forceFirstRequest: true);
|
||||||
|
await apiServer.StartAsync();
|
||||||
|
var tokensPath = TokensPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var client = new YavscApiClient(new Settings(), new TokenStore(tokensPath));
|
||||||
|
await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
client.GetAnonymousAsync<List<StubApiServer.Post>>(
|
||||||
|
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken));
|
||||||
|
Assert.Equal(1, apiServer.RequestCount);
|
||||||
|
Assert.Empty(apiServer.SeenBearers);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(tokensPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public async Task GetAnonymousAsync_does_not_require_or_send_session_credentials(bool hasSession)
|
||||||
|
{
|
||||||
|
using var apiServer = new StubApiServer();
|
||||||
|
await apiServer.StartAsync();
|
||||||
|
var tokensPath = TokensPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store = new TokenStore(tokensPath);
|
||||||
|
if (hasSession)
|
||||||
|
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
|
||||||
|
DateTimeOffset.UtcNow.AddHours(1), null));
|
||||||
|
await using var client = new YavscApiClient(new Settings(), store);
|
||||||
|
|
||||||
|
var posts = await client.GetAnonymousAsync<List<StubApiServer.Post>>(
|
||||||
|
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(2, posts.Count);
|
||||||
|
Assert.Equal(1, apiServer.RequestCount);
|
||||||
|
Assert.Empty(apiServer.SeenBearers);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(tokensPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static int GetFreePort()
|
private static int GetFreePort()
|
||||||
{
|
{
|
||||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ public static class ServiceCollectionHelpers
|
||||||
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||||
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
||||||
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
||||||
|
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||||
|
|
||||||
|
|
@ -93,6 +94,7 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton(estimateClient);
|
services.AddSingleton(estimateClient);
|
||||||
services.AddSingleton(frontClient);
|
services.AddSingleton(frontClient);
|
||||||
services.AddSingleton(resourceUsageClient);
|
services.AddSingleton(resourceUsageClient);
|
||||||
|
services.AddSingleton(notificationsClient);
|
||||||
services.AddSingleton(userFilesClient);
|
services.AddSingleton(userFilesClient);
|
||||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||||
|
|
||||||
private RefreshTokenRecord? _tokens;
|
private RefreshTokenRecord? _tokens;
|
||||||
|
private static readonly HttpRequestOptionsKey<bool> AnonymousRequest = new("PostIt.AnonymousRequest");
|
||||||
|
|
||||||
|
public virtual async Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||||
|
request.Options.Set(AnonymousRequest, true);
|
||||||
|
using var response = await Http.SendAsync(request, ct).ConfigureAwait(false);
|
||||||
|
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||||
|
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
|
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
|
||||||
{
|
{
|
||||||
|
|
@ -614,7 +624,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_owner._tokens is not null)
|
if (_owner._tokens is not null
|
||||||
|
&& !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous))
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||||
"Bearer", _owner._tokens.AccessToken);
|
"Bearer", _owner._tokens.AccessToken);
|
||||||
return base.SendAsync(request, cancellationToken);
|
return base.SendAsync(request, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels.Chat;
|
using PostIt.ViewModels.Chat;
|
||||||
|
using Yavsc.Abstract.Models.Messaging;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
public class HomePageViewModel : ViewModelBase
|
public class HomePageViewModel : ViewModelBase
|
||||||
|
|
@ -13,6 +18,26 @@ public class HomePageViewModel : ViewModelBase
|
||||||
public YavscApiClient Api { get; }
|
public YavscApiClient Api { get; }
|
||||||
public Settings Settings { get; }
|
public Settings Settings { get; }
|
||||||
public SessionStatusViewModel SessionStatus { get; }
|
public SessionStatusViewModel SessionStatus { get; }
|
||||||
|
public ObservableCollection<Notification> Notifications { get; } = new();
|
||||||
|
private readonly NotificationsApiClient? _notificationsClient;
|
||||||
|
private int _notificationsLoadVersion;
|
||||||
|
private bool _isLoadingNotifications;
|
||||||
|
private string? _notificationsError;
|
||||||
|
private bool _notificationsLoaded;
|
||||||
|
|
||||||
|
public bool IsLoadingNotifications
|
||||||
|
{
|
||||||
|
get => _isLoadingNotifications;
|
||||||
|
private set => SetProperty(ref _isLoadingNotifications, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? NotificationsError
|
||||||
|
{
|
||||||
|
get => _notificationsError;
|
||||||
|
private set => SetProperty(ref _notificationsError, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasNoNotifications => _notificationsLoaded && Notifications.Count == 0;
|
||||||
|
|
||||||
private string _welcomeText = "Welcome to PostIt!";
|
private string _welcomeText = "Welcome to PostIt!";
|
||||||
public string WelcomeText
|
public string WelcomeText
|
||||||
|
|
@ -24,11 +49,16 @@ public class HomePageViewModel : ViewModelBase
|
||||||
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
|
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
|
||||||
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
|
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
|
||||||
|
|
||||||
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
|
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus,
|
||||||
|
NotificationsApiClient notificationsClient)
|
||||||
{
|
{
|
||||||
Api = api;
|
Api = api;
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
SessionStatus = sessionStatus;
|
SessionStatus = sessionStatus;
|
||||||
|
_notificationsClient = notificationsClient;
|
||||||
|
SessionStatus.PropertyChanged += OnSessionStatusChanged;
|
||||||
|
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
|
||||||
|
() => _notificationsClient is not null);
|
||||||
|
|
||||||
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||||
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
||||||
|
|
@ -55,6 +85,59 @@ public class HomePageViewModel : ViewModelBase
|
||||||
public IAsyncRelayCommand OpenClientEstimates { get; }
|
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||||
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||||
public IAsyncRelayCommand OpenMyFiles { get; }
|
public IAsyncRelayCommand OpenMyFiles { get; }
|
||||||
|
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
|
||||||
|
|
||||||
|
public async Task RefreshNotificationsAsync()
|
||||||
|
{
|
||||||
|
if (_notificationsClient is null)
|
||||||
|
throw new InvalidOperationException("Notifications client is not available.");
|
||||||
|
|
||||||
|
var version = ++_notificationsLoadVersion;
|
||||||
|
Notifications.Clear();
|
||||||
|
NotificationsError = null;
|
||||||
|
_notificationsLoaded = false;
|
||||||
|
OnPropertyChanged(nameof(HasNoNotifications));
|
||||||
|
IsLoadingNotifications = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var notifications = SessionStatus.IsLoggedIn
|
||||||
|
? await _notificationsClient.GetCurrentAsync()
|
||||||
|
: await _notificationsClient.GetPublicAsync();
|
||||||
|
if (version != _notificationsLoadVersion)
|
||||||
|
return;
|
||||||
|
if (notifications is null)
|
||||||
|
throw new InvalidOperationException("The notifications response is empty.");
|
||||||
|
|
||||||
|
foreach (var notification in notifications)
|
||||||
|
Notifications.Add(notification);
|
||||||
|
_notificationsLoaded = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (version == _notificationsLoadVersion)
|
||||||
|
NotificationsError = $"Impossible de charger les notifications : {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (version == _notificationsLoadVersion)
|
||||||
|
{
|
||||||
|
IsLoadingNotifications = false;
|
||||||
|
OnPropertyChanged(nameof(HasNoNotifications));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn) || _notificationsClient is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
|
||||||
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
|
_ = RefreshNotificationsAsync();
|
||||||
|
else
|
||||||
|
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OpenActivitiesAsync()
|
private async Task OpenActivitiesAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -228,7 +311,7 @@ public class HomePageViewModel : ViewModelBase
|
||||||
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
|
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
|
||||||
/// designer-only duplicate instance is therefore harmless.
|
/// designer-only duplicate instance is therefore harmless.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
|
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!)
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,50 @@
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
xmlns:lang="using:Yavsc.Lang"
|
xmlns:lang="using:Yavsc.Lang"
|
||||||
|
xmlns:messaging="using:Yavsc.Abstract.Models.Messaging"
|
||||||
x:Class="PostIt.Views.HomePage"
|
x:Class="PostIt.Views.HomePage"
|
||||||
x:DataType="vm:HomePageViewModel"
|
x:DataType="vm:HomePageViewModel"
|
||||||
Header="Home">
|
Header="Home">
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:HomePageViewModel />
|
<vm:HomePageViewModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
<StackPanel HorizontalAlignment="Center"
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||||
VerticalAlignment="Center"
|
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
|
||||||
Spacing="12">
|
HorizontalAlignment="Stretch">
|
||||||
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
|
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
|
||||||
FontSize="22"
|
FontSize="22"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Text="Notifications" FontSize="20" FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" Content="Actualiser"
|
||||||
|
Command="{Binding RefreshNotificationsCommand}"
|
||||||
|
IsEnabled="{Binding !IsLoadingNotifications}"/>
|
||||||
|
</Grid>
|
||||||
|
<ProgressBar IsIndeterminate="True" Height="4"
|
||||||
|
IsVisible="{Binding IsLoadingNotifications}"/>
|
||||||
|
<TextBlock Text="{Binding NotificationsError}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource SystemControlErrorTextForegroundBrush}"/>
|
||||||
|
<TextBlock Text="Aucune notification pour le moment."
|
||||||
|
IsVisible="{Binding HasNoNotifications}" TextWrapping="Wrap"/>
|
||||||
|
<ItemsControl x:Name="NotificationsList" ItemsSource="{Binding Notifications}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="messaging:Notification">
|
||||||
|
<Border Padding="12" Margin="0,0,0,8" CornerRadius="6"
|
||||||
|
BorderThickness="1"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
<Button Content="Les Salons de discussion"
|
<Button Content="Les Salons de discussion"
|
||||||
Command="{Binding OpenChat}"
|
Command="{Binding OpenChat}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
|
|
@ -61,4 +91,5 @@
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -8,4 +9,12 @@ public partial class HomePage : ContentPage
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnNavigatedTo(e);
|
||||||
|
if (!Design.IsDesignMode && DataContext is HomePageViewModel vm
|
||||||
|
&& vm.RefreshNotificationsCommand.CanExecute(null))
|
||||||
|
_ = vm.RefreshNotificationsAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ public interface IYavscApiClient : IAsyncDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
HttpClient Http { get; }
|
HttpClient Http { get; }
|
||||||
|
|
||||||
|
/// <summary>Read a public JSON endpoint without attaching session credentials.</summary>
|
||||||
|
Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException("Anonymous requests are not supported by this transport.");
|
||||||
|
|
||||||
/// <summary>Call a JSON endpoint with a typed return value.</summary>
|
/// <summary>Call a JSON endpoint with a typed return value.</summary>
|
||||||
/// <param name="method">HTTP verb.</param>
|
/// <param name="method">HTTP verb.</param>
|
||||||
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
|
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ public sealed class NotificationsApiClient
|
||||||
ct: ct);
|
ct: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default)
|
||||||
|
=> _api.GetAnonymousAsync<List<Notification>>(Absolute(PathPrefix), ct);
|
||||||
|
|
||||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||||
|
|
||||||
private Uri ResolveBusinessBaseAddress()
|
private Uri ResolveBusinessBaseAddress()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue