feat(notifications): support dismissing notifications in PostIt

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-18 12:49:52 +01:00
commit cb8cf9406d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
12 changed files with 389 additions and 181 deletions

View file

@ -90,6 +90,22 @@ public class HomePageNotificationsTests
session.Refresh();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
var note = Note(3, "Dismiss me");
vm.Notifications.Add(note);
Assert.True(vm.DismissNotificationCommand.CanExecute(note));
transport.DismissResponse = Task.FromException(new HttpRequestException("Server unavailable"));
await vm.DismissNotificationCommand.ExecuteAsync(note);
Assert.Contains(note, vm.Notifications);
Assert.Contains("Server unavailable", vm.NotificationsError);
transport.DismissResponse = Task.CompletedTask;
await vm.DismissNotificationCommand.ExecuteAsync(note);
Assert.Equal(HttpMethod.Post, transport.LastMethod);
Assert.Equal("https://business.example/api/v1/dismiss/click/3", transport.LastPath);
Assert.DoesNotContain(note, vm.Notifications);
Assert.Null(vm.NotificationsError);
var privateResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = privateResponse.Task;
var privateLoad = vm.RefreshNotificationsAsync();
@ -112,6 +128,42 @@ public class HomePageNotificationsTests
}
}
[Fact]
public async Task Anonymous_dismiss_removes_the_notification_without_an_api_call()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
vm.Notifications.Add(Note(1, "Keep"));
vm.Notifications.Add(Note(2, "Dismiss me"));
Assert.True(vm.DismissNotificationCommand.CanExecute(vm.Notifications[1]));
Assert.False(vm.DismissNotificationCommand.CanExecute(null));
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[1]);
Assert.Null(transport.LastMethod);
Assert.Null(transport.LastPath);
Assert.Equal(1, Assert.Single(vm.Notifications).Id);
Assert.Null(vm.NotificationsError);
}
[Fact]
public async Task Anonymous_dismiss_of_the_last_notification_updates_the_empty_state()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
transport.Response = Task.FromResult(new List<Notification> { Note(3, "Public") });
await vm.RefreshNotificationsAsync();
Assert.False(vm.HasNoNotifications);
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[0]);
Assert.Empty(vm.Notifications);
Assert.True(vm.HasNoNotifications);
Assert.Null(vm.NotificationsError);
Assert.Null(transport.LastMethod);
}
[AvaloniaFact]
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
{
@ -138,6 +190,12 @@ public class HomePageNotificationsTests
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
b => b.Command == vm.RefreshNotificationsCommand);
Assert.True(refresh.IsEnabled);
var dismiss = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
b => b.Command == vm.DismissNotificationCommand);
Assert.Same(vm.Notifications[0], dismiss.CommandParameter);
Assert.Equal("Close", dismiss.Content);
Assert.True(dismiss.IsVisible);
Assert.True(dismiss.IsEnabled);
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
s => s.Extent.Height > s.Viewport.Height);
@ -145,6 +203,10 @@ public class HomePageNotificationsTests
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
await nav.PopAsync();
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[0]);
window.UpdateLayout();
Assert.Empty(vm.Notifications);
Assert.Null(transport.LastMethod);
}
finally
{
@ -173,10 +235,29 @@ public class HomePageNotificationsTests
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
LastMethod = method;
if (method == HttpMethod.Post)
{
LastPath = path;
WasAnonymous = false;
return DismissAsync<T>(path);
}
Assert.Equal(HttpMethod.Get, method);
return ReadAsync<T>(path, false);
}
private async Task<T> DismissAsync<T>(string path)
{
await DismissResponse;
return (T)(object)new DismissedNotification
{
NotificationId = long.Parse(path[(path.LastIndexOf('/') + 1)..])
};
}
public HttpMethod? LastMethod { get; private set; }
public Task DismissResponse { get; set; } = Task.CompletedTask;
private async Task<T> ReadAsync<T>(string path, bool anonymous)
{
LastPath = path;

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Threading;
@ -59,6 +60,9 @@ public class HomePageViewModel : ViewModelBase
SessionStatus.PropertyChanged += OnSessionStatusChanged;
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
() => _notificationsClient is not null);
DismissNotificationCommand = new AsyncRelayCommand<Notification?>(DismissNotificationAsync,
n => n is not null,
AsyncRelayCommandOptions.AllowConcurrentExecutions);
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
@ -86,6 +90,31 @@ public class HomePageViewModel : ViewModelBase
public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; }
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
public IAsyncRelayCommand<Notification?> DismissNotificationCommand { get; }
public async Task DismissNotificationAsync(Notification? notification)
{
if (notification is null)
return;
NotificationsError = null;
try
{
if (SessionStatus.IsLoggedIn)
{
if (_notificationsClient is null)
throw new InvalidOperationException("Notifications client is not available.");
await _notificationsClient.DismissAsync(notification.Id);
}
var shown = Notifications.FirstOrDefault(n => n.Id == notification.Id);
if (shown is not null)
Notifications.Remove(shown);
OnPropertyChanged(nameof(HasNoNotifications));
}
catch (Exception ex)
{
NotificationsError = $"Impossible de masquer la notification : {ex.Message}";
}
}
public async Task RefreshNotificationsAsync()
{
@ -134,9 +163,15 @@ public class HomePageViewModel : ViewModelBase
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
if (Dispatcher.UIThread.CheckAccess())
_ = RefreshNotificationsAsync();
OnLoginChanged();
else
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
Dispatcher.UIThread.Post(OnLoginChanged);
}
private void OnLoginChanged()
{
DismissNotificationCommand.NotifyCanExecuteChanged();
_ = RefreshNotificationsAsync();
}
private async Task OpenActivitiesAsync()

View file

@ -37,8 +37,15 @@
BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Spacing="6">
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Margin="8,0,0,0"
Content="{Binding click_action, TargetNullValue=Fermer}"
ToolTip.Tip="Masquer cette notification"
Command="{Binding $parent[ItemsControl].((vm:HomePageViewModel)DataContext).DismissNotificationCommand}"
CommandParameter="{Binding}"/>
</Grid>
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
</StackPanel>
</Border>