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`
|
||||
pour exposer les notifications publiques et ciblées à PostIt sur la
|
||||
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
|
||||
|
||||
|
|
|
|||
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>
|
||||
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()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ public static class ServiceCollectionHelpers
|
|||
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
||||
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
||||
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||
|
||||
|
|
@ -93,6 +94,7 @@ public static class ServiceCollectionHelpers
|
|||
services.AddSingleton(estimateClient);
|
||||
services.AddSingleton(frontClient);
|
||||
services.AddSingleton(resourceUsageClient);
|
||||
services.AddSingleton(notificationsClient);
|
||||
services.AddSingleton(userFilesClient);
|
||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||
|
||||
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)
|
||||
{
|
||||
|
|
@ -614,7 +624,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
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(
|
||||
"Bearer", _owner._tokens.AccessToken);
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels.Chat;
|
||||
using Yavsc.Abstract.Models.Messaging;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public class HomePageViewModel : ViewModelBase
|
||||
|
|
@ -13,6 +18,26 @@ public class HomePageViewModel : ViewModelBase
|
|||
public YavscApiClient Api { get; }
|
||||
public Settings Settings { 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!";
|
||||
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 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;
|
||||
Settings = settings;
|
||||
SessionStatus = sessionStatus;
|
||||
_notificationsClient = notificationsClient;
|
||||
SessionStatus.PropertyChanged += OnSessionStatusChanged;
|
||||
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
|
||||
() => _notificationsClient is not null);
|
||||
|
||||
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
||||
|
|
@ -55,6 +85,59 @@ public class HomePageViewModel : ViewModelBase
|
|||
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||
public IAsyncRelayCommand OpenProviderEstimates { 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()
|
||||
{
|
||||
|
|
@ -228,7 +311,7 @@ public class HomePageViewModel : ViewModelBase
|
|||
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
|
||||
/// designer-only duplicate instance is therefore harmless.
|
||||
/// </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:vm="using:PostIt.ViewModels"
|
||||
xmlns:lang="using:Yavsc.Lang"
|
||||
xmlns:messaging="using:Yavsc.Abstract.Models.Messaging"
|
||||
x:Class="PostIt.Views.HomePage"
|
||||
x:DataType="vm:HomePageViewModel"
|
||||
Header="Home">
|
||||
<Design.DataContext>
|
||||
<vm:HomePageViewModel />
|
||||
</Design.DataContext>
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
|
||||
HorizontalAlignment="Stretch">
|
||||
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
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"
|
||||
Command="{Binding OpenChat}"
|
||||
HorizontalAlignment="Center"
|
||||
|
|
@ -61,4 +91,5 @@
|
|||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Avalonia.Controls;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
|
|
@ -8,4 +9,12 @@ public partial class HomePage : ContentPage
|
|||
{
|
||||
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>
|
||||
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>
|
||||
/// <param name="method">HTTP verb.</param>
|
||||
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ public sealed class NotificationsApiClient
|
|||
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 Uri ResolveBusinessBaseAddress()
|
||||
|
|
|
|||
Loading…
Reference in a new issue