Merge branch 'main' into feature/estimate
Some checks failed
Dotnet build and test / build (pull_request) Failing after 23s
Some checks failed
Dotnet build and test / build (pull_request) Failing after 23s
This commit is contained in:
commit
2d675cef74
74 changed files with 1644 additions and 478 deletions
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4
|
||||
steps:
|
||||
- name: Clone yavsc
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
release:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4
|
||||
steps:
|
||||
- name: Clone du repo au tag demandé
|
||||
env:
|
||||
|
|
|
|||
83
CHANGELOG.md
83
CHANGELOG.md
|
|
@ -1,7 +1,88 @@
|
|||
# Changelog
|
||||
|
||||
## [1.0.8-rc22] - unstable
|
||||
|
||||
## [1.0.8-rc19] - Blogs UX
|
||||
### Fixed
|
||||
|
||||
User experience login into PostIt
|
||||
|
||||
## [1.0.8-rc21] - unstable
|
||||
|
||||
### Fixed
|
||||
|
||||
User experience editing the blog article.
|
||||
|
||||
## [1.0.8-rc20] - unstable
|
||||
|
||||
Chat SignalR fonctionnel de bout en bout (connexion, salons multiples,
|
||||
envoi/réception de messages), enregistreur audio et localisation dans
|
||||
PostIt, intégration Live.Avalonia démarrée, et reverse-proxy nginx
|
||||
apprêté pour WebSocket.
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Interface de chat : page `ChatPage` (connexion au hub,
|
||||
saisie du nom de salon, liste des messages et des membres présents),
|
||||
entrée depuis la page d'accueil.
|
||||
* [PostIt] Envoi de messages dans un salon joint : DTO `ChatMessage`
|
||||
(Name/Room/Message) pour l'événement `ReceiveMessage`, saisie avec
|
||||
envoi sur `Enter`, commande inactive tant qu'aucune salle n'est en
|
||||
focus (le hub refuse l'envoi hors salon, `LabNoJoinNoSend`).
|
||||
* [PostIt] Modèle multi-salles côté client : `JoinedRooms`,
|
||||
`SelectedRoom` (salle en focus), messages et utilisateurs indexés par
|
||||
salle (`FocusedMessages`/`FocusedUsers`), ComboBox de choix de la
|
||||
salle active ; un `Join` sur une salle déjà jointe ne fait que
|
||||
changer le focus.
|
||||
* [PostIt] Enregistreur audio (`AudioRecorder`, interface
|
||||
`IPCMProcessor`) et ressources de localisation
|
||||
(`Resources.{resx,en-EN,pt-PT}`).
|
||||
* [PostIt][WIP] Début d'intégration Live.Avalonia (preview XAML à
|
||||
chaud ; encore KO).
|
||||
* [contrib] Bloc `location /chathub` dans `template.vhost` : upgrade
|
||||
WebSocket (`Upgrade`/`Connection`), `proxy_read_timeout 3600s` et
|
||||
`proxy_buffering off` — sans quoi la connexion SignalR tombait au
|
||||
premier silence ou l'upgrade échouait derrière nginx.
|
||||
|
||||
### Changed
|
||||
|
||||
* [PostIt] `StatusNotice` déplacé de `Models` vers `ViewModels` et
|
||||
enrichi d'une commande d'action optionnelle (`CheckCommand` +
|
||||
`CheckCommandLabel`, interface `IWithLabel`) : le bandeau de statut
|
||||
propose désormais l'action contextuelle (Connecter / Déconnecter /
|
||||
Reconnecter / Vérifier la connexion).
|
||||
* [PostIt] `ChatViewModel` suit le cycle de vie complet de la
|
||||
connexion : handlers `Reconnecting` / `Reconnected` / `Closed` de
|
||||
`WithAutomaticReconnect`, pour refléter l'état réel au lieu d'un
|
||||
`IsConnected` optimiste.
|
||||
* [Yavsc.Api] `AddSignalR().AddJsonProtocol(IncludeFields = true)` :
|
||||
`ChatRoomInfo` n'expose que des champs publics, que System.Text.Json
|
||||
ne sérialise pas par défaut — le client recevait des objets vides
|
||||
(`Name` null, `Users` vide) et la liste des salons joints n'était
|
||||
jamais alimentée.
|
||||
* [Yavsc.Server] `IConnexionManager.OnConnected` prend le `userName`
|
||||
en paramètre (résolu depuis les claims dans `ChatHub`) au lieu de le
|
||||
relire depuis un dictionnaire jamais alimenté.
|
||||
* [PostIt.Android] `network_security_config.xml` déplacé sous
|
||||
`Resources/xml/` (chemin canonique Android).
|
||||
|
||||
### Fixed
|
||||
|
||||
* [Yavsc.Server] `KeyNotFoundException` dans
|
||||
`HubConnectionManager.OnConnected` à chaque connexion SignalR :
|
||||
`ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique
|
||||
d'un `SetUserName` disparu lors d'une refactorisation. L'exception
|
||||
dans `OnConnectedAsync` faisait fermer la connexion par le hub, et
|
||||
PostIt affichait « Déconnecté (The server closed the connection…) »
|
||||
immédiatement après la connexion.
|
||||
* [PostIt] URL du hub SignalR : retrait du préfixe `/api/v1` de
|
||||
`ApiUrl` avant d'apposer `Constants.ChatHubPath` (le hub est mappé à
|
||||
la racine du service, pas sous le préfixe versionné).
|
||||
* [PostIt] La `StatusBar` de la page chat référençait `Grid.Row="4"`
|
||||
sur une grille à 3 rangées (rangée hors limites, bandeau invisible).
|
||||
|
||||
## [1.0.8-rc19] - unstable
|
||||
|
||||
[PostIt] Blogs UX
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -232,6 +232,12 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d
|
|||
* ~/Views/Home/Index.cshtml
|
||||
* ~/Views/Home/About.cshtml
|
||||
|
||||
# 👥 Crédits & Co-autoraClaudt
|
||||
|
||||
Ce projet a été développé de manière hybride :
|
||||
- **Conception, architecture et revue de code :** Paul Schneider
|
||||
- **Assistance au code et optimisation :** IA générative (Gemini, Google, Claude (Anthropic), https://qoder.com/ (ex-Windsurf))
|
||||
|
||||
# Remerciements
|
||||
|
||||
Photo de <a href="https://unsplash.com/fr/@dewang?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Dewang Gupta</a>sur <a href="https://unsplash.com/fr/photos/homme-debout-pres-dun-arbre-Mu3T3DmvQQw?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ reinstall: copy-binaries
|
|||
@echo "💯 backend started"
|
||||
|
||||
reinstall-prod:
|
||||
make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
|
||||
make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
|
||||
|
||||
generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
|
||||
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
|
||||
|
|
|
|||
|
|
@ -57,5 +57,25 @@ server {
|
|||
proxy_read_timeout 100s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# SignalR (ChatHub) : WebSocket exige Upgrade/Connection et un timeout
|
||||
# long, sinon la connexion tombe au premier silence ou l'upgrade échoue.
|
||||
# Connection est fixé à "upgrade" (pas de map $connection_upgrade, qui
|
||||
# exigerait une directive map {} globale hors de ce vhost) : SignalR
|
||||
# négocie toujours un upgrade sur ce chemin, et la négociation initiale
|
||||
# (POST /negotiate, sans en-tête Upgrade) n'est pas affectée par cet
|
||||
# en-tête côté Kestrel.
|
||||
location /chathub {
|
||||
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
include server-status.conf;
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AvaloniaVersionBase>12.1.2</AvaloniaVersionBase>
|
||||
<AvaloniaVersionBase>12.1.3</AvaloniaVersionBase>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
||||
|
|
@ -13,16 +13,21 @@
|
|||
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Headless" Version="12.1.2" />
|
||||
<PackageVersion Include="Avalonia.Headless" Version="12.1.3" />
|
||||
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
|
||||
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
|
||||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.20.0" />
|
||||
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.12" />
|
||||
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
|
||||
<PackageVersion Include="PvRecorder" Version="1.2.13" />
|
||||
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application android:label="PostIt" android:icon="@drawable/Icon">
|
||||
<application
|
||||
android:label="PostIt"
|
||||
android:icon="@drawable/Icon"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:hardwareAccelerated="true">
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">10.0.2.2</domain> <!-- Debug address -->
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using Avalonia;
|
||||
using PostIt.Services;
|
||||
using PostIt.CLIOptions;
|
||||
|
||||
namespace PostIt.Desktop;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Net.Http;
|
||||
using PostIt.Models;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc;
|
||||
using Yavsc.Api.Client;
|
||||
|
|
|
|||
34
src/PostIt/PostIt.Tests/MD/le-chant-1.md
Normal file
34
src/PostIt/PostIt.Tests/MD/le-chant-1.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
|
||||
Loin vers l'infini s'étendent
|
||||
Les grands prés marécageux .
|
||||
Pas un seul oiseau ne chante
|
||||
Sur les arbres secs et creux !
|
||||
|
||||
*(Refrain)*
|
||||
|
||||
Ô terre de détresse,
|
||||
Où nous devons sans cesse,
|
||||
Piocher, piocher .
|
||||
|
||||
Dans ce camp morne et sauvage
|
||||
Entouré de murs de fer,
|
||||
Il nous semble vivre en cage
|
||||
Au milieu d'un grand désert .
|
||||
|
||||
Bruit des pas et bruit des armes,
|
||||
Sentinelles jours et nuits,
|
||||
Et du sang, et des cris, des larmes,
|
||||
La mort pour celui qui fuit .
|
||||
|
||||
Mais un jour dans notre vie,
|
||||
Le printemps refleurira .
|
||||
Liberté, liberté chérie
|
||||
Je dirai : « Tu es à moi ! »
|
||||
|
||||
*(Dernier refrain)*
|
||||
|
||||
Ô terre d'allégresse,
|
||||
Où nous pourrons sans cesse,
|
||||
Aimer, aimer !
|
||||
|
||||
[audio : Mario Hacquard - Youtube](https://www.youtube.com/watch?v=ogw_mgk1pFs)
|
||||
|
|
@ -101,7 +101,7 @@ public class SessionStatusBannerTests
|
|||
{
|
||||
MainWindow window = new MainWindow();
|
||||
window.Show();
|
||||
var banner = window.MainView.SessionBanner;
|
||||
var banner = new SessionStatusBanner();
|
||||
var status = new SessionStatusViewModel();
|
||||
banner.DataContext = status;
|
||||
return banner;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using Avalonia.Controls;
|
|||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.VisualTree;
|
||||
using PostIt.Controls;
|
||||
using PostIt.Models;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
|
@ -21,7 +22,9 @@ public class StatusBarTests
|
|||
[AvaloniaFact]
|
||||
public void Status_message_is_rendered_by_a_SelectableTextBlock()
|
||||
{
|
||||
var bar = new StatusBar { DataContext = StatusNotice.Error("Échec de la suppression : boom") };
|
||||
var bar = new StatusBar {
|
||||
DataContext =
|
||||
StatusNotice.Error("Échec de la suppression : boom") };
|
||||
var window = new Window { Content = bar };
|
||||
window.Show();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,18 +6,26 @@ using Avalonia.Controls;
|
|||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using PostIt.Helpers;
|
||||
using System.Globalization;
|
||||
using Live.Avalonia;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using ReactiveUI;
|
||||
using System.Reactive;
|
||||
|
||||
namespace PostIt;
|
||||
|
||||
public partial class App : Application
|
||||
public partial class App : Application, ILiveView, IVMPusherApp
|
||||
{
|
||||
public static bool LiveMode { get; private set; }
|
||||
private int _bootStarted;
|
||||
public string? CliConfigFileSpecification { get; private set; }
|
||||
public static string? CliConfigFileSpecification { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// DI container the platform entry points hand to ViewModels so
|
||||
|
|
@ -36,15 +44,24 @@ public partial class App : Application
|
|||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
#if DEBUG
|
||||
this.AttachDeveloperTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (TryHandOffCustomSchemeUrl()) return;
|
||||
|
||||
this.ServiceProvider = new ServiceCollection().BuildServices();
|
||||
this.ServiceProvider = new ServiceCollection().BuildPostItServices();
|
||||
var settings = ServiceProvider.GetRequiredService<Settings>();
|
||||
|
||||
if (!LiveMode)
|
||||
{
|
||||
// Debugging requires pdb loading etc, so we disable live reloading
|
||||
// during a test run with an attached debugger.
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
InitializeClassicDesktopLifetime(settings, desktop);
|
||||
|
|
@ -57,15 +74,102 @@ public partial class App : Application
|
|||
{
|
||||
InitializeSingleViewLifetime(settings, singleViewPlatform);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Here, we create a new LiveViewHost, located in the 'Live.Avalonia'
|
||||
// namespace, and pass an ILiveView implementation to it. The ILiveView
|
||||
// implementation should have a parameterless constructor! Next, we
|
||||
// start listening for any changes in the source files. And then, we
|
||||
// show the LiveViewHost window. Simple enough, huh?
|
||||
var window = new LiveViewHost(this, Console.WriteLine);
|
||||
var view = ServiceProvider!.GetRequiredService<MainView>();
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
InitializeClassicDesktopLifetime(window, view, settings, desktop);
|
||||
}
|
||||
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
|
||||
{
|
||||
InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
|
||||
}
|
||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
|
||||
{
|
||||
InitializeSingleViewLifetime(settings, singleViewPlatform);
|
||||
}
|
||||
|
||||
window.StartWatchingSourceFilesForHotReloading();
|
||||
Console.WriteLine("LiveViewHost started for hot reloading.");
|
||||
window.Show();
|
||||
}
|
||||
|
||||
|
||||
#if DEBUG
|
||||
// Here we subscribe to ReactiveUI default exception handler to avoid app
|
||||
// termination in case if we do something wrong in our view models. See:
|
||||
// https://www.reactiveui.net/docs/handbook/default-exception-handler/
|
||||
//
|
||||
// In case if you are using another MV* framework, please refer to its
|
||||
// documentation explaining global exception handling.
|
||||
Console.WriteLine("Debug mode: ReactiveUI default exception handler is set.");
|
||||
RxApp.DefaultExceptionHandler = Observer.Create<Exception>(Console.WriteLine);
|
||||
#endif
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private bool IsProduction()
|
||||
{
|
||||
#if DEBUG
|
||||
return false;
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
public async Task<Page> PushPageAsync(ViewModelBase viewModel)
|
||||
{
|
||||
var template = DataTemplates.FirstOrDefault(t => t.Match(viewModel));
|
||||
if (template is null)
|
||||
{
|
||||
throw new InvalidOperationException($"No IDataTemplate found for {viewModel.GetType().Name}.");
|
||||
}
|
||||
|
||||
Control? view = template.Build(viewModel) ;
|
||||
if (view is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Template for {viewModel.GetType().Name} returned <null>.");
|
||||
}
|
||||
|
||||
var page = view as Page;
|
||||
if (page is null)
|
||||
{
|
||||
// NavigationPage expects Page instances. Wrap any fallback control
|
||||
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
|
||||
page = new ContentPage { Content = view };
|
||||
}
|
||||
|
||||
page.DataContext = viewModel;
|
||||
|
||||
MainView? mainView = (Current as App).View;
|
||||
|
||||
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
|
||||
var stack = mainView.NavRoot.NavigationStack;
|
||||
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
|
||||
{
|
||||
return page;
|
||||
}
|
||||
|
||||
await mainView.NavRoot.PushAsync(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform)
|
||||
{
|
||||
singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>();
|
||||
ConfigureRootView(View);
|
||||
ApplyDarkMode(settings);
|
||||
ApplyStettings(settings);
|
||||
}
|
||||
|
||||
public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
|
||||
|
|
@ -74,7 +178,7 @@ public partial class App : Application
|
|||
() =>
|
||||
{
|
||||
this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>());
|
||||
ApplyDarkMode(settings);
|
||||
ApplyStettings(settings);
|
||||
return View!;
|
||||
};
|
||||
}
|
||||
|
|
@ -82,16 +186,23 @@ public partial class App : Application
|
|||
public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var window = ServiceProvider!.GetRequiredService<MainWindow>();
|
||||
desktop.MainWindow = window;
|
||||
this.ConfigureRootView(window.MainView);
|
||||
var mainView = (MainView)CreateView(window);
|
||||
InitializeClassicDesktopLifetime(window, mainView, settings, desktop);
|
||||
}
|
||||
|
||||
ApplyDarkMode(settings);
|
||||
public void InitializeClassicDesktopLifetime(Window window, MainView mainView, Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
this.ConfigureRootView(mainView);
|
||||
ApplyStettings(settings);
|
||||
desktop.MainWindow = window;
|
||||
window.Content = mainView;
|
||||
window.Show();
|
||||
}
|
||||
|
||||
public void ConfigureRootView(MainView rootView)
|
||||
{
|
||||
// Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran.
|
||||
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
|
||||
{
|
||||
rootView.AttachedToVisualTree += (_, _) =>
|
||||
Dispatcher.UIThread.Post(() => _ = BootOnceAsync());
|
||||
|
||||
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
|
||||
sessionStatus.LogoutCompleted += () =>
|
||||
|
|
@ -99,20 +210,11 @@ public partial class App : Application
|
|||
// Remplacer Window.NavRoot par rootView.NavRoot
|
||||
rootView.NavRoot.PopToRootAsync();
|
||||
};
|
||||
if (rootView.DataContext==null)
|
||||
rootView.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
|
||||
|
||||
rootView.SessionBanner.DataContext = sessionStatus;
|
||||
this.View = rootView;
|
||||
}
|
||||
|
||||
private async Task BootOnceAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
|
||||
await BootAsync(this.ServiceProvider!, api);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -126,12 +228,26 @@ public partial class App : Application
|
|||
View = mainView ?? throw new ArgumentNullException(nameof(mainView));
|
||||
}
|
||||
|
||||
private static void ApplyDarkMode(Settings settings)
|
||||
private static void ApplyStettings(Settings settings)
|
||||
{
|
||||
Application.Current!.RequestedThemeVariant =
|
||||
if (!string.IsNullOrWhiteSpace(settings.SelectedLanguage))
|
||||
Yavsc.Lang.Resources.Culture = new CultureInfo(settings.SelectedLanguage);
|
||||
Current!.RequestedThemeVariant =
|
||||
settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
|
||||
}
|
||||
|
||||
|
||||
private async Task BootOnceAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
|
||||
await BootAsync(this.ServiceProvider!, api);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run once after the main window is shown: try to refresh the
|
||||
/// cached OIDC tokens silently; on success, push MainPage on top
|
||||
|
|
@ -147,9 +263,9 @@ public partial class App : Application
|
|||
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
|
||||
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
|
||||
sessionStatus.Refresh();
|
||||
var homePage = provider.GetRequiredService<HomePageViewModel>();
|
||||
var homePageVm = provider.GetRequiredService<HomePageViewModel>();
|
||||
var app = (App)Current!;
|
||||
await app.PushPageAsync(homePage);
|
||||
await app.PushPageAsync(homePageVm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -205,8 +321,31 @@ public partial class App : Application
|
|||
await View!.NavRoot.PopAsync();
|
||||
}
|
||||
|
||||
internal void UseConfigFileWhenLoading(string configFile)
|
||||
internal static void UseConfigFileWhenLoading(string configFile)
|
||||
{
|
||||
this.CliConfigFileSpecification = configFile;
|
||||
CliConfigFileSpecification = configFile;
|
||||
}
|
||||
|
||||
internal static void EnableLiveMode()
|
||||
{
|
||||
LiveMode = true;
|
||||
}
|
||||
|
||||
public object CreateView(Window window)
|
||||
{
|
||||
if (this.ServiceProvider == null)
|
||||
{
|
||||
var serviceCollection = new ServiceCollection();
|
||||
this.ServiceProvider = serviceCollection.BuildPostItServices();
|
||||
}
|
||||
if (window.DataContext == null)
|
||||
window.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
|
||||
|
||||
// The AppView class will inherit the DataContext
|
||||
// of the window. The AppView class can be a
|
||||
// UserControl, a Grid, a TextBlock, whatever.
|
||||
this.View = ServiceProvider!.GetRequiredService<MainView>();
|
||||
this.ConfigureRootView(this.View);
|
||||
return this.View;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,21 @@ using Mono.Options;
|
|||
using PostIt;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.CLIOptions;
|
||||
|
||||
public static class CLIOptionHandler
|
||||
{
|
||||
public static void HandleArgs(string[] args)
|
||||
{
|
||||
bool showHelp = false;
|
||||
string configFile = null;
|
||||
bool liveMode = false;
|
||||
|
||||
var options = new OptionSet {
|
||||
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v },
|
||||
{ "l|live", "Enable live mode", v => liveMode = true },
|
||||
{ "v|version", "Show the application version", v => Console.WriteLine("PostIt version "
|
||||
+ typeof(App).Assembly.GetName().Version?.ToString()) },
|
||||
{ "h|help", "Show this message and exit", v => showHelp = true }
|
||||
};
|
||||
|
||||
|
|
@ -26,8 +32,11 @@ public static class CLIOptionHandler
|
|||
}
|
||||
if (configFile != null)
|
||||
{
|
||||
var app = App.Current as App;
|
||||
app.UseConfigFileWhenLoading(configFile);
|
||||
App.UseConfigFileWhenLoading(configFile);
|
||||
}
|
||||
if (liveMode)
|
||||
{
|
||||
App.EnableLiveMode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml
Normal file
61
src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<UserControl
|
||||
x:Class="PostIt.Controls.MarkdownEditorControl"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||
xmlns:md="using:MarkdownViewer.Core.Controls"
|
||||
x:Name="Root"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="260">
|
||||
<Border
|
||||
BorderBrush="LightGray"
|
||||
BorderThickness="1"
|
||||
CornerRadius="5"
|
||||
IsVisible="{Binding !IsPreviewVisible, ElementName=Root}"
|
||||
Padding="0"
|
||||
MinHeight="260">
|
||||
<ScrollViewer
|
||||
BringIntoViewOnFocusChange="False"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<AvaloniaEdit:TextEditor
|
||||
x:Name="SourceEditor"
|
||||
Document="{Binding Document, ElementName=Root, Mode=TwoWay}"
|
||||
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
|
||||
ShowLineNumbers="True"
|
||||
WordWrap="True"
|
||||
BorderThickness="0"
|
||||
Padding="5,5,5,15"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
TextInputOptions.Multiline="True"
|
||||
TextInputOptions.ReturnKeyType="Default" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
BorderBrush="LightGray"
|
||||
BorderThickness="1"
|
||||
CornerRadius="5"
|
||||
IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
|
||||
Padding="8"
|
||||
MinHeight="260">
|
||||
<ScrollViewer
|
||||
BringIntoViewOnFocusChange="False"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<md:MarkdownViewer
|
||||
x:Name="PreviewViewer"
|
||||
MarkdownText="{Binding MarkdownText, ElementName=Root}"
|
||||
ClipToBounds="False" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
88
src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs
Normal file
88
src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using AvaloniaEdit.Document;
|
||||
|
||||
namespace PostIt.Controls;
|
||||
|
||||
public partial class MarkdownEditorControl : UserControl
|
||||
{
|
||||
public static readonly StyledProperty<string?> MarkdownTextProperty =
|
||||
AvaloniaProperty.Register<MarkdownEditorControl, string?>(nameof(MarkdownText));
|
||||
|
||||
public static readonly StyledProperty<TextDocument?> DocumentProperty =
|
||||
AvaloniaProperty.Register<MarkdownEditorControl, TextDocument?>(nameof(Document));
|
||||
|
||||
public static readonly StyledProperty<bool> IsPreviewVisibleProperty =
|
||||
AvaloniaProperty.Register<MarkdownEditorControl, bool>(nameof(IsPreviewVisible), false);
|
||||
|
||||
public string? MarkdownText
|
||||
{
|
||||
get => GetValue(MarkdownTextProperty);
|
||||
set => SetValue(MarkdownTextProperty, value);
|
||||
}
|
||||
|
||||
public TextDocument? Document
|
||||
{
|
||||
get => GetValue(DocumentProperty);
|
||||
set => SetValue(DocumentProperty, value);
|
||||
}
|
||||
|
||||
public bool IsPreviewVisible
|
||||
{
|
||||
get => GetValue(IsPreviewVisibleProperty);
|
||||
set => SetValue(IsPreviewVisibleProperty, value);
|
||||
}
|
||||
|
||||
public MarkdownEditorControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
PropertyChanged += OnPropertyChanged;
|
||||
SizeChanged += OnSizeChanged;
|
||||
UpdateEditorLimits();
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property == MarkdownTextProperty)
|
||||
{
|
||||
var text = e.NewValue as string ?? string.Empty;
|
||||
if (Document is null || !string.Equals(Document.Text, text, StringComparison.Ordinal))
|
||||
{
|
||||
Document = new TextDocument(text);
|
||||
}
|
||||
}
|
||||
else if (e.Property == DocumentProperty)
|
||||
{
|
||||
var newDocument = e.NewValue as TextDocument;
|
||||
var text = newDocument?.Text ?? string.Empty;
|
||||
if (!string.Equals(MarkdownText, text, StringComparison.Ordinal))
|
||||
{
|
||||
MarkdownText = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
UpdateEditorLimits();
|
||||
}
|
||||
|
||||
private void UpdateEditorLimits()
|
||||
{
|
||||
var visibleHeight = Bounds.Height > 0 ? Bounds.Height - 24 : 260;
|
||||
visibleHeight = Math.Max(visibleHeight, 80);
|
||||
|
||||
if (SourceEditor is not null)
|
||||
{
|
||||
SourceEditor.MaxHeight = visibleHeight;
|
||||
SourceEditor.Height = double.NaN;
|
||||
}
|
||||
|
||||
if (PreviewViewer is not null)
|
||||
{
|
||||
PreviewViewer.MaxHeight = visibleHeight;
|
||||
PreviewViewer.Height = double.NaN;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,13 +13,16 @@
|
|||
FontWeight="Bold"
|
||||
Foreground="{Binding Foreground}"
|
||||
VerticalAlignment="Top"/>
|
||||
<!-- SelectableTextBlock so the user can select + copy the
|
||||
status message (TextBlock is not selectable in
|
||||
Avalonia). Keeps Text/TextWrapping/Foreground. -->
|
||||
<SelectableTextBlock Text="{Binding Message}"
|
||||
Foreground="{Binding Foreground}"
|
||||
TextWrapping="Wrap"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Content="{Binding CheckCommandLabel}"
|
||||
Foreground="{Binding Foreground}"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasCheckCommand}"
|
||||
Command="{Binding CheckCommand}"
|
||||
/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ using System;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.ViewModels.Chat;
|
||||
using PostIt.Views;
|
||||
using PostIt.Views.Blogs;
|
||||
using PostIt.Views.Chat;
|
||||
using PostIt.Views.Commands;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
|
|
@ -11,7 +13,7 @@ namespace PostIt.Helpers;
|
|||
|
||||
public static class ServiceCollectionHelpers
|
||||
{
|
||||
public static IServiceProvider BuildServices(this ServiceCollection services)
|
||||
public static IServiceProvider BuildPostItServices(this ServiceCollection services)
|
||||
{
|
||||
var settings = new Settings();
|
||||
settings.Load();
|
||||
|
|
@ -37,9 +39,13 @@ public static class ServiceCollectionHelpers
|
|||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||
|
||||
// Vues
|
||||
services.AddSingleton<MainView>();
|
||||
services.AddSingleton<BlogsPage>();
|
||||
services.AddSingleton<MainWindow>();
|
||||
services.AddTransient<MainWindow>();
|
||||
services.AddTransient<MainView>();
|
||||
services.AddTransient<BlogsPage>();
|
||||
|
||||
|
||||
// Models
|
||||
services.AddSingleton<ChatViewModel>();
|
||||
|
||||
// SettingsPage is a singleton: there must be one and only one
|
||||
// instance of the settings UI for the lifetime of the app.
|
||||
|
|
@ -59,6 +65,7 @@ public static class ServiceCollectionHelpers
|
|||
services.AddSingleton<CirclesPage>();
|
||||
services.AddSingleton<ActivitiesPage>();
|
||||
services.AddTransient<CommandFormsPage>();
|
||||
services.AddTransient<ChatPage>();
|
||||
services.AddTransient<RdvPage>();
|
||||
services.AddTransient<BrushPage>();
|
||||
services.AddTransient<BillingQueriesPage>();
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Helpers;
|
||||
|
||||
public static class ViewModelBaseHelpers
|
||||
{
|
||||
public static async Task<Page> PushPageAsync(this App app, ViewModelBase vm)
|
||||
{
|
||||
var window = app.View;
|
||||
if (window is null)
|
||||
{
|
||||
throw new InvalidOperationException("MainWindow is not initialized yet.");
|
||||
}
|
||||
|
||||
var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm));
|
||||
if (template is null)
|
||||
{
|
||||
throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
|
||||
}
|
||||
|
||||
var view = template.Build(vm);
|
||||
if (view is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Template for {vm.GetType().Name} returned <null>.");
|
||||
}
|
||||
|
||||
var page = view as Page;
|
||||
if (page is null)
|
||||
{
|
||||
// NavigationPage expects Page instances. Wrap any fallback control
|
||||
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
|
||||
page = new ContentPage { Content = view };
|
||||
}
|
||||
|
||||
page.DataContext = vm;
|
||||
|
||||
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
|
||||
var stack = window.NavRoot.NavigationStack;
|
||||
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
|
||||
{
|
||||
return page;
|
||||
}
|
||||
|
||||
await window.NavRoot.PushAsync(page);
|
||||
|
||||
return page;
|
||||
}
|
||||
}
|
||||
10
src/PostIt/PostIt/IVMPusherApp.cs
Normal file
10
src/PostIt/PostIt/IVMPusherApp.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using System.Threading.Tasks;
|
||||
using PostIt.ViewModels;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace PostIt;
|
||||
|
||||
internal interface IVMPusherApp
|
||||
{
|
||||
Task<Page> PushPageAsync(ViewModelBase viewModel);
|
||||
}
|
||||
6
src/PostIt/PostIt/Interfaces/IPCMProcessor.cs
Normal file
6
src/PostIt/PostIt/Interfaces/IPCMProcessor.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
using System;
|
||||
|
||||
public interface IPCMProcessor : IDisposable
|
||||
{
|
||||
void Process(short[] audioFrame);
|
||||
}
|
||||
72
src/PostIt/PostIt/Lang/Resources.Designer.cs
generated
Normal file
72
src/PostIt/PostIt/Lang/Resources.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace Yavsc.Lang {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
public class Resources {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
public Resources() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
public static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Yavsc.Lang.Resources", typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
public static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Bienvenue dans PostIt !!!.
|
||||
/// </summary>
|
||||
public static string GreetingText {
|
||||
get {
|
||||
return ResourceManager.GetString("GreetingText", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/PostIt/PostIt/Lang/Resources.en-EN.resx
Normal file
5
src/PostIt/PostIt/Lang/Resources.en-EN.resx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<root>
|
||||
<data name="GreetingText">
|
||||
<value>Welcome to PostIt!</value>
|
||||
</data>
|
||||
</root>
|
||||
5
src/PostIt/PostIt/Lang/Resources.pt-PT.resx
Normal file
5
src/PostIt/PostIt/Lang/Resources.pt-PT.resx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<root>
|
||||
<data name="GreetingText">
|
||||
<value>Bemvindo em PostIt!</value>
|
||||
</data>
|
||||
</root>
|
||||
5
src/PostIt/PostIt/Lang/Resources.resx
Normal file
5
src/PostIt/PostIt/Lang/Resources.resx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<root>
|
||||
<data name="GreetingText">
|
||||
<value>Bienvenue dans PostIt !!!</value>
|
||||
</data>
|
||||
</root>
|
||||
15
src/PostIt/PostIt/Models/ChatMessage.cs
Normal file
15
src/PostIt/PostIt/Models/ChatMessage.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace PostIt.ViewModels.Chat
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO de message de salon, calqué sur la charge envoyée par
|
||||
/// <c>ChatHub.Send</c> côté serveur ({ Name, Room, Message }).
|
||||
/// Le protocole JSON de SignalR sérialise en camelCase ; la
|
||||
/// désérialisation est insensible à la casse par défaut.
|
||||
/// </summary>
|
||||
public class ChatMessage
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Room { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
}
|
||||
14
src/PostIt/PostIt/Models/ChatMessageDto.cs
Normal file
14
src/PostIt/PostIt/Models/ChatMessageDto.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace PostIt.ViewModels.Chat
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO minimal calqué sur l'objet anonyme émis par le hub
|
||||
/// (<c>{ Name, Room, Message }</c>) via l'événement
|
||||
/// <c>ReceiveMessage</c>.
|
||||
/// </summary>
|
||||
public class ChatMessageDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Room { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
}
|
||||
11
src/PostIt/PostIt/Models/ChatRoomInfo.cs
Normal file
11
src/PostIt/PostIt/Models/ChatRoomInfo.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace PostIt.ViewModels.Chat
|
||||
{
|
||||
// DTO minimal calqué sur votre logique serveur ChatRoomInfo
|
||||
public class ChatRoomInfo
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public List<string> Users { get; set; } = new();
|
||||
}
|
||||
}
|
||||
6
src/PostIt/PostIt/Models/IWithLabel.cs
Normal file
6
src/PostIt/PostIt/Models/IWithLabel.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace PostIt.ViewModels;
|
||||
|
||||
internal interface IWithLabel
|
||||
{
|
||||
string Label { get; }
|
||||
}
|
||||
8
src/PostIt/PostIt/Models/StatusSeverity.cs
Normal file
8
src/PostIt/PostIt/Models/StatusSeverity.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace PostIt.Models;
|
||||
|
||||
public enum StatusSeverity
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
|
|
@ -30,16 +32,36 @@
|
|||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" />
|
||||
<PackageReference Include="Live.Avalonia" />
|
||||
<PackageReference Include="Mapsui" />
|
||||
<PackageReference Include="Mapsui.Avalonia12" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="IdentityModel.OidcClient" />
|
||||
<PackageReference Include="MarkdownViewer.Core" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Mono.Options" />
|
||||
<PackageReference Include="PvRecorder" />
|
||||
<PackageReference Include="Silk.NET.OpenAL" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
|
||||
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Lang/Resources.resx">
|
||||
<Generator>PublicResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Lang/Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Lang/Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Watch Include="**\*.xaml" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
67
src/PostIt/PostIt/Services/AudioRecorder.cs
Normal file
67
src/PostIt/PostIt/Services/AudioRecorder.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using Pv;
|
||||
|
||||
public class AudioRecorder : IDisposable
|
||||
{
|
||||
private int frameLength;
|
||||
PvRecorder recorder;
|
||||
|
||||
Thread thread;
|
||||
short[] audioFrame;
|
||||
IPCMProcessor processor;
|
||||
|
||||
public bool processing { get; private set; }
|
||||
|
||||
public AudioRecorder(IPCMProcessor processor, int frameLength=512, int deviceIndex = -1)
|
||||
{
|
||||
this.frameLength = frameLength;
|
||||
this.recorder = PvRecorder.Create(frameLength: frameLength, deviceIndex: deviceIndex);
|
||||
this.audioFrame = new short[frameLength];
|
||||
this.processor=processor;
|
||||
}
|
||||
|
||||
public static string [] GetAvailableDevices()
|
||||
{
|
||||
return PvRecorder.GetAvailableDevices();
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
recorder.Start();
|
||||
thread = new Thread(ThreadLoop);
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
public void ThreadLoop()
|
||||
{
|
||||
while (recorder.IsRecording)
|
||||
{
|
||||
if (thread.ThreadState != ThreadState.Running)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (processing)
|
||||
{
|
||||
Thread.Sleep(10);
|
||||
continue;
|
||||
}
|
||||
processing = true;
|
||||
audioFrame = recorder.Read();
|
||||
processor.Process(audioFrame);
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
thread.Interrupt();
|
||||
thread.Join();
|
||||
recorder.Stop();
|
||||
thread = null;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
recorder.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,10 @@ using Avalonia.Controls.Templates;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.ViewModels.Commands;
|
||||
using PostIt.ViewModels.Chat;
|
||||
using PostIt.Views;
|
||||
using PostIt.Views.Blogs;
|
||||
using PostIt.Views.Chat;
|
||||
using PostIt.Views.Commands;
|
||||
|
||||
namespace PostIt;
|
||||
|
|
@ -58,6 +60,7 @@ public class ViewLocator : IDataTemplate
|
|||
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
||||
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
|
||||
ChatViewModel => services.GetRequiredService<ChatPage>(),
|
||||
null => new TextBlock { Text = "No view for <null>" },
|
||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ using Avalonia;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Helpers;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Api.Client.Dtos;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ using Yavsc.Api.Client.Dtos;
|
|||
using Yavsc.Abstract.BlogSpot;
|
||||
using Yavsc.Abstract.Identity.Security;
|
||||
using System.Net.Http;
|
||||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using Avalonia;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Helpers;
|
||||
using PostIt.Models;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ using PostIt.Helpers;
|
|||
using Yavsc;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Avalonia;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Helpers;
|
||||
using PostIt.Models;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ using Avalonia;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Helpers;
|
||||
using PostIt.Models;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using Avalonia;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Helpers;
|
||||
using PostIt.Models;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ using PostIt.Helpers;
|
|||
using Yavsc;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Client;
|
||||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ using Yavsc.Api.Client;
|
|||
using Yavsc.Abstract.Files;
|
||||
using PostIt.Helpers;
|
||||
using AvaloniaEdit.Document;
|
||||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -38,18 +39,64 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
/// <summary>Editor buffer for the post body. Same pattern as
|
||||
/// <see cref="DraftTitle"/>.</summary>
|
||||
|
||||
public string DraftArticle
|
||||
public string? DraftArticle
|
||||
{
|
||||
get => DraftArticleDocument.Text;
|
||||
set {
|
||||
get => DraftArticleDocument?.Text ?? null;
|
||||
set
|
||||
{
|
||||
var currentText = DraftArticleDocument?.Text;
|
||||
if (string.Equals(currentText, value, StringComparison.Ordinal))
|
||||
{
|
||||
OnPropertyChanged(nameof(DraftArticle));
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanging(nameof(DraftArticle));
|
||||
DraftArticleDocument = new TextDocument(value);
|
||||
this.OnPropertyChanged(nameof(DraftArticle));
|
||||
try
|
||||
{
|
||||
DraftArticleDocument = value is not null ? new TextDocument(value) : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error setting DraftArticleDocument: {ex.Message}";
|
||||
// Handle any exceptions that might occur when setting the DraftArticleDocument
|
||||
}
|
||||
OnPropertyChanged(nameof(DraftArticle));
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
public partial TextDocument DraftArticleDocument {get; set;}
|
||||
private TextDocument? _draftArticleDocument;
|
||||
|
||||
public TextDocument? DraftArticleDocument
|
||||
{
|
||||
get => _draftArticleDocument;
|
||||
set
|
||||
{
|
||||
if (ReferenceEquals(_draftArticleDocument, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_draftArticleDocument is not null)
|
||||
{
|
||||
_draftArticleDocument.TextChanged -= DraftArticleDocument_TextChanged;
|
||||
}
|
||||
|
||||
_draftArticleDocument = value;
|
||||
|
||||
if (_draftArticleDocument is not null)
|
||||
{
|
||||
_draftArticleDocument.TextChanged += DraftArticleDocument_TextChanged;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(DraftArticleDocument));
|
||||
}
|
||||
}
|
||||
|
||||
private void DraftArticleDocument_TextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
OnPropertyChanged(nameof(DraftArticle));
|
||||
}
|
||||
|
||||
/// <summary>Editor buffer for the post's publication state.
|
||||
/// Reflects the server-side <c>IsPublished</c> flag (the
|
||||
|
|
|
|||
353
src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs
Normal file
353
src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using Yavsc;
|
||||
|
||||
namespace PostIt.ViewModels.Chat
|
||||
{
|
||||
/// <summary>
|
||||
/// ViewModel pour la gestion du chat,
|
||||
/// incluant la connexion au Hub SignalR,
|
||||
/// la gestion des salons et des utilisateurs connectés.
|
||||
/// </summary>
|
||||
|
||||
public partial class ChatViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Settings _settings;
|
||||
private readonly YavscApiClient? _api;
|
||||
|
||||
// Le paramètre optionnel permet l'instanciation design-time
|
||||
// (Design.DataContext dans ChatPage.axaml) sans conteneur DI.
|
||||
public ChatViewModel(YavscApiClient? api = null)
|
||||
{
|
||||
var app = App.Current as App;
|
||||
_settings = app!.ServiceProvider!.GetRequiredService<Settings>(); // Assurez-vous que _settings est initialisé avant utilisation.
|
||||
_api = api;
|
||||
Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
|
||||
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
|
||||
FocusedMessages = _systemMessages;
|
||||
}
|
||||
|
||||
private HubConnection? _connection;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string CurrentRoom { get; set; } = "General";
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string NickName { get; set; } = "";
|
||||
|
||||
[ObservableProperty]
|
||||
public partial StatusNotice Status { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
|
||||
public partial bool IsConnected { get; set; } = false;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
|
||||
public partial string MessageToSend { get; set; } = "";
|
||||
|
||||
// Salles jointes (choix de la salle en focus dans la vue).
|
||||
public ObservableCollection<string> JoinedRooms { get; } = new();
|
||||
|
||||
// Salle en focus ; null = aucune salle, le journal affiche
|
||||
// alors les notifications système.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
|
||||
public partial string? SelectedRoom { get; set; }
|
||||
|
||||
// Notifications globales (connexion, erreurs de join, ...)
|
||||
private readonly ObservableCollection<string> _systemMessages = new();
|
||||
|
||||
// Messages et utilisateurs présents, indexés par salle.
|
||||
private readonly Dictionary<string, ObservableCollection<string>> _messagesByRoom = new();
|
||||
private readonly Dictionary<string, ObservableCollection<string>> _usersByRoom = new();
|
||||
private static readonly ObservableCollection<string> _noUsers = new();
|
||||
|
||||
// Vues "focus" exposées à la page : pointent vers la salle
|
||||
// sélectionnée, ou vers le journal système / une liste vide.
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<string> FocusedMessages { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<string> FocusedUsers { get; set; } = _noUsers;
|
||||
|
||||
partial void OnSelectedRoomChanged(string? value)
|
||||
{
|
||||
FocusedMessages = value != null && _messagesByRoom.TryGetValue(value, out var msgs)
|
||||
? msgs : _systemMessages;
|
||||
FocusedUsers = value != null && _usersByRoom.TryGetValue(value, out var users)
|
||||
? users : _noUsers;
|
||||
}
|
||||
|
||||
// Trace une notification système dans la vue courante
|
||||
// (et dans le journal global pour consultation ultérieure).
|
||||
private void Log(string line)
|
||||
{
|
||||
_systemMessages.Add(line);
|
||||
if (!ReferenceEquals(FocusedMessages, _systemMessages))
|
||||
FocusedMessages.Add(line);
|
||||
}
|
||||
|
||||
// Commandes pour les boutons
|
||||
[RelayCommand]
|
||||
public async Task ConnectCommand() => await ConnectAsync();
|
||||
|
||||
[RelayCommand]
|
||||
public async Task JoinRoomCommand() => await JoinRoomAsync();
|
||||
|
||||
// Le serveur (ChatHub.Send) refuse tout message d'un utilisateur
|
||||
// qui n'a pas joint le salon ("NoJoinNoSend") : on garde la
|
||||
// commande inactive tant qu'aucune salle n'est en focus.
|
||||
private bool CanSendMessage() =>
|
||||
IsConnected && SelectedRoom != null && !string.IsNullOrWhiteSpace(MessageToSend);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSendMessage))]
|
||||
private async Task SendMessageAsync()
|
||||
{
|
||||
if (_connection == null || SelectedRoom == null) return;
|
||||
var text = MessageToSend.Trim();
|
||||
try
|
||||
{
|
||||
await _connection.InvokeAsync("Send", SelectedRoom, text);
|
||||
MessageToSend = string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Erreur d'envoi : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public override bool CanNavigateNext { get; protected set; } = false;
|
||||
public override bool CanNavigatePrevious { get; protected set; } = true;
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
if (_connection != null)
|
||||
{
|
||||
await _connection.StopAsync();
|
||||
await _connection.DisposeAsync();
|
||||
_connection = null;
|
||||
}
|
||||
IsConnected = false;
|
||||
SelectedRoom = null;
|
||||
JoinedRooms.Clear();
|
||||
_messagesByRoom.Clear();
|
||||
_usersByRoom.Clear();
|
||||
_systemMessages.Clear();
|
||||
Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
|
||||
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info,
|
||||
new RelayCheckCommand("Vérifier la connexion", async () => await CheckConnectionAsync()));
|
||||
|
||||
if (_api == null || !_api.HasValidSession)
|
||||
{
|
||||
Status = new StatusNotice("Session expirée : reconnectez-vous avant d'ouvrir le chat.", StatusSeverity.Error,
|
||||
new RelayCheckCommand("Reconnecter", async () => await ConnectAsync()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Le hub est mappé à la racine du service ("/chathub"),
|
||||
// pas sous le préfixe versionné "/api/v1" : on le retire.
|
||||
var hubBase = _settings.ApiUrl.TrimEnd('/');
|
||||
if (hubBase.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase))
|
||||
hubBase = hubBase[..^"/api/v1".Length];
|
||||
var hubUrl = hubBase + "/" + Constants.ChatHubPath;
|
||||
|
||||
_connection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl,
|
||||
|
||||
options =>
|
||||
{
|
||||
// Le hub est [Authorize] : on attache le Bearer OIDC.
|
||||
// AccessTokenProvider est rappelé à chaque reconnexion,
|
||||
// donc on tente un refresh silencieux à chaque appel.
|
||||
// Un refresh mort (invalid_grant) ne doit pas faire
|
||||
// échouer StartAsync avec une exception interne : on
|
||||
// renvoie le token courant (éventuellement expiré) et
|
||||
// le serveur répondra un 401 propre, surfacé à l'UI.
|
||||
options.AccessTokenProvider = async () =>
|
||||
{
|
||||
await _api.TrySilentLoginAsync();
|
||||
return _api.CurrentAccessToken;
|
||||
};
|
||||
})
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
// 1. Ecoute des notifications globales (notifyUser)
|
||||
_connection.On<string, string, string>("notifyUser", (type, user, msg) =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
Log($"[{type}] {user}: {msg}"));
|
||||
});
|
||||
|
||||
// 2. Ecoute des notifications de salon (notifyRoom)
|
||||
_connection.On<string, string, string>("notifyRoom", (type, room, user) =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (_messagesByRoom.TryGetValue(room, out var roomLog))
|
||||
roomLog.Add($"*{user} a interagi avec le salon {room} (Action: {type})");
|
||||
else
|
||||
Log($"*{user} a interagi avec le salon {room} (Action: {type})");
|
||||
if (type == "UserJoin" && _usersByRoom.TryGetValue(room, out var users)
|
||||
&& !users.Contains(user))
|
||||
users.Add(user);
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Réception de l'état complet suite au Join réussi (joint)
|
||||
_connection.On<ChatRoomInfo>("joint", (roomInfo) =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => OnRoomJoined(roomInfo));
|
||||
});
|
||||
|
||||
// 4. Réception des messages de salon (diffusés par ChatHub.Send)
|
||||
_connection.On<ChatMessage>("ReceiveMessage", (msg) =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var room = msg.Room ?? "?";
|
||||
if (!_messagesByRoom.TryGetValue(room, out var roomLog))
|
||||
roomLog = _messagesByRoom[room] = new();
|
||||
roomLog.Add($"{msg.Name} : {msg.Message}");
|
||||
});
|
||||
});
|
||||
|
||||
await _connection.StartAsync();
|
||||
IsConnected = true;
|
||||
Status = new StatusNotice("Connecté au Hub SignalR", StatusSeverity.Info, new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
|
||||
|
||||
// WithAutomaticReconnect ne lève pas d'exception en cas de
|
||||
// perte réseau : l'état passe par Reconnecting/Closed. Sans
|
||||
// ces handlers, IsConnected resterait optimiste et Join()
|
||||
// échouerait avec "connection is not active".
|
||||
_connection.Reconnecting += error =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
IsConnected = false;
|
||||
Status = new StatusNotice($"Connexion perdue ({error?.Message ?? "réseau"}), reconnexion...", StatusSeverity.Error,
|
||||
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
_connection.Reconnected += _ =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
IsConnected = true;
|
||||
Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
|
||||
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
_connection.Closed += error =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
IsConnected = false;
|
||||
Status = new StatusNotice($"Déconnecté ({error?.Message ?? "fin de connexion"})", StatusSeverity.Info,
|
||||
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Status = new StatusNotice($"Erreur : {ex.Message}", StatusSeverity.Error, new RelayCheckCommand("Reconnecter", async () => await ConnectAsync()));
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Alimente JoinedRooms et les collections par salle à partir
|
||||
// du ChatRoomInfo renvoyé par le hub (valeur de retour du Join
|
||||
// et/ou événement "joint"). Idempotent.
|
||||
private void OnRoomJoined(ChatRoomInfo roomInfo)
|
||||
{
|
||||
// ChatHub.Join renseigne toujours Name (salle existante ou
|
||||
// créée) ; un Name nul signalerait un payload inattendu.
|
||||
var roomName = roomInfo.Name ?? CurrentRoom;
|
||||
if (roomInfo.Name == null)
|
||||
Log("OnRoomJoined : nom de salle absent du payload, repli sur la salle demandée.");
|
||||
if (!_messagesByRoom.TryGetValue(roomName, out var roomLog))
|
||||
roomLog = _messagesByRoom[roomName] = new();
|
||||
roomLog.Add($"Salon rejoint avec succès : {roomName}");
|
||||
var users = _usersByRoom[roomName] = new();
|
||||
foreach (var u in roomInfo.Users) users.Add(u);
|
||||
if (!JoinedRooms.Contains(roomName))
|
||||
JoinedRooms.Add(roomName);
|
||||
SelectedRoom = roomName;
|
||||
}
|
||||
|
||||
private Task CheckConnectionAsync()
|
||||
{
|
||||
var state = _connection?.State.ToString() ?? "aucune connexion";
|
||||
Log($"État de la connexion : {state}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task JoinRoomAsync()
|
||||
{
|
||||
if (_connection == null || string.IsNullOrWhiteSpace(CurrentRoom)) return;
|
||||
|
||||
// Salle déjà jointe : on se contente de la mettre en focus.
|
||||
if (JoinedRooms.Contains(CurrentRoom))
|
||||
{
|
||||
SelectedRoom = CurrentRoom;
|
||||
return;
|
||||
}
|
||||
|
||||
// InvokeCoreAsync exige une connexion active : avec la
|
||||
// reconnexion automatique, l'état peut être Reconnecting au
|
||||
// moment de l'appel. On tente une (re)connexion si nécessaire.
|
||||
if (_connection.State != HubConnectionState.Connected)
|
||||
{
|
||||
Log($"Connexion non active ({_connection.State}), tentative de reconnexion...");
|
||||
try
|
||||
{
|
||||
await _connection.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Reconnexion impossible : {ex.Message}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Appel de la méthode publique 'Join' du Hub (renvoie un ChatRoomInfo)
|
||||
var info = await _connection.InvokeAsync<ChatRoomInfo>("Join", CurrentRoom);
|
||||
if (info == null)
|
||||
{
|
||||
Log("Erreur lors de la validation du salon par le serveur.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// On alimente le modèle depuis la valeur de retour,
|
||||
// sans attendre l'événement "joint".
|
||||
OnRoomJoined(info);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Erreur Join: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/PostIt/PostIt/ViewModels/Chat/RelayCheckCommand.cs
Normal file
33
src/PostIt/PostIt/ViewModels/Chat/RelayCheckCommand.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace PostIt.ViewModels.Chat
|
||||
{
|
||||
internal class RelayCheckCommand : ICommand, IWithLabel
|
||||
{
|
||||
public string Label { get; }
|
||||
private Func<Task> value;
|
||||
|
||||
public RelayCheckCommand(string v, Func<Task> value)
|
||||
{
|
||||
this.Label = v;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
|
||||
public bool CanExecute(object? parameter)
|
||||
{
|
||||
return value != null;
|
||||
}
|
||||
|
||||
public void Execute(object? parameter)
|
||||
{
|
||||
if (value != null)
|
||||
{
|
||||
_ = value();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Models;
|
||||
using Yavsc;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Client;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using System.Net;
|
|||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using PostIt.Models;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models.Billing;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public interface IActionStatusViewModel
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ using System.Threading.Tasks;
|
|||
using Avalonia;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Helpers;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels.Chat;
|
||||
using Yavsc.Api.Client;
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -36,7 +36,11 @@ public class HomePageViewModel : ViewModelBase
|
|||
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
|
||||
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
||||
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
|
||||
OpenChat = new AsyncRelayCommand(OpenChatAsync);
|
||||
}
|
||||
|
||||
|
||||
public IAsyncRelayCommand OpenChat { get; }
|
||||
public IAsyncRelayCommand OpenBlogs { get; }
|
||||
public IAsyncRelayCommand OpenActivities { get; }
|
||||
public IAsyncRelayCommand OpenProviderRequests { get; }
|
||||
|
|
@ -103,6 +107,14 @@ public class HomePageViewModel : ViewModelBase
|
|||
await app.PushPageAsync(vm);
|
||||
}
|
||||
|
||||
private async Task OpenChatAsync()
|
||||
{
|
||||
var app = (App?)Application.Current;
|
||||
var vm = app!.ServiceProvider?.GetRequiredService<ChatViewModel>();
|
||||
await app.PushPageAsync(vm!);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
|
||||
/// servie par le host Blogs via <c>api/v1/fs</c>.
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
namespace PostIt.ViewModels;
|
||||
|
||||
public enum StatusSeverity
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed class StatusNotice
|
||||
{
|
||||
public string Message { get; }
|
||||
public StatusSeverity Severity { get; }
|
||||
public string Glyph { get; }
|
||||
public string Background { get; }
|
||||
public string BorderBrush { get; }
|
||||
public string Foreground { get; }
|
||||
|
||||
private StatusNotice(string message, StatusSeverity severity)
|
||||
{
|
||||
Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
|
||||
Severity = severity;
|
||||
|
||||
(Glyph, Background, BorderBrush, Foreground) = severity switch
|
||||
{
|
||||
StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
|
||||
StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
|
||||
_ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
|
||||
};
|
||||
}
|
||||
|
||||
public static StatusNotice Info(string message) => new(message, StatusSeverity.Info);
|
||||
public static StatusNotice Warning(string message) => new(message, StatusSeverity.Warning);
|
||||
public static StatusNotice Error(string message) => new(message, StatusSeverity.Error);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ using Avalonia;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Helpers;
|
||||
using PostIt.Models;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ using System.IO;
|
|||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Globalization;
|
||||
using PostIt.Models;
|
||||
|
||||
[assembly: InternalsVisibleTo("PostIt.Tests")]
|
||||
|
||||
|
|
@ -44,6 +46,7 @@ public partial class Settings : ViewModelBase
|
|||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
|
||||
|
||||
|
||||
[JsonIgnore]
|
||||
public bool Loaded { get; private set; } = false;
|
||||
|
||||
|
||||
|
|
@ -59,8 +62,16 @@ public partial class Settings : ViewModelBase
|
|||
/// <see cref="Authentication"/>.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[JsonIgnore]
|
||||
public partial bool IsDirty { get; private set; } = false;
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
|
||||
|
||||
public string SelectedLanguage { get; internal set; } = CultureInfo.CurrentUICulture.Name;
|
||||
|
||||
/// <summary>
|
||||
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
|
||||
|
|
@ -261,10 +272,9 @@ public partial class Settings : ViewModelBase
|
|||
}
|
||||
|
||||
var app = App.Current as App;
|
||||
if (app!=null)
|
||||
if (app.CliConfigFileSpecification!=null)
|
||||
if (App.CliConfigFileSpecification!=null)
|
||||
{
|
||||
SettingsFileFullName = app.CliConfigFileSpecification;
|
||||
SettingsFileFullName = App.CliConfigFileSpecification;
|
||||
}
|
||||
|
||||
if (SettingsFileFullName is not null)
|
||||
|
|
@ -532,7 +542,4 @@ public partial class Settings : ViewModelBase
|
|||
/// </summary>
|
||||
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
|
||||
|
||||
|
||||
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
|
||||
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
|
||||
}
|
||||
|
|
|
|||
40
src/PostIt/PostIt/ViewModels/StatusNotice.cs
Normal file
40
src/PostIt/PostIt/ViewModels/StatusNotice.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using System.Windows.Input;
|
||||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public sealed class StatusNotice
|
||||
{
|
||||
public string Message { get; }
|
||||
public StatusSeverity Severity { get; }
|
||||
public string Glyph { get; }
|
||||
public string Background { get; }
|
||||
public string BorderBrush { get; }
|
||||
public string Foreground { get; }
|
||||
|
||||
public bool HasCheckCommand { get => CheckCommand != null; }
|
||||
|
||||
public ICommand? CheckCommand { get; }
|
||||
|
||||
public string CheckCommandLabel { get; }
|
||||
|
||||
public StatusNotice(string message, StatusSeverity severity, ICommand? checkCommand)
|
||||
{
|
||||
Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
|
||||
Severity = severity;
|
||||
|
||||
(Glyph, Background, BorderBrush, Foreground) = severity switch
|
||||
{
|
||||
StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
|
||||
StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
|
||||
_ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
|
||||
};
|
||||
CheckCommand = checkCommand;
|
||||
CheckCommandLabel = HasCheckCommand ?
|
||||
(checkCommand is IWithLabel labeled ? labeled.Label : "Check") : string.Empty;
|
||||
}
|
||||
|
||||
public static StatusNotice Info(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Info, checkCommand);
|
||||
public static StatusNotice Warning(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Warning, checkCommand);
|
||||
public static StatusNotice Error(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Error, checkCommand);
|
||||
}
|
||||
|
|
@ -17,10 +17,14 @@
|
|||
<Design.DataContext>
|
||||
<vm:BlogsViewModel />
|
||||
</Design.DataContext>
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
|
||||
Margin="5,5,10,5" HorizontalAlignment="Stretch" BringIntoViewOnFocusChange="False"
|
||||
VerticalAlignment="Stretch">
|
||||
<StackPanel>
|
||||
<ScrollViewer
|
||||
Margin="5,5,10,5"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
BringIntoViewOnFocusChange="False">
|
||||
<StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||
|
||||
<WrapPanel x:Name="BlogsHeaderPanel"
|
||||
Margin="5"
|
||||
|
|
@ -87,7 +91,7 @@
|
|||
<PathIcon Data="{StaticResource PublishedIcon}" />
|
||||
</CommandBarToggleButton.Icon>
|
||||
</CommandBarToggleButton>
|
||||
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Markdown Viewer">
|
||||
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
|
||||
<CommandBarToggleButton.Icon>
|
||||
<PathIcon Data="{StaticResource MarkdownIcon}" />
|
||||
</CommandBarToggleButton.Icon>
|
||||
|
|
@ -103,7 +107,7 @@
|
|||
</CommandBar.PrimaryCommands>
|
||||
</CommandBar>
|
||||
|
||||
<Grid Margin="12" RowSpacing="12" x:Name="MainGrid">
|
||||
<Grid Margin="12" RowSpacing="12" x:Name="MainGrid" MinHeight="500">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
|
|
@ -137,26 +141,8 @@
|
|||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
Grid.Row="2"
|
||||
Padding="8"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
BorderBrush="Gray"
|
||||
BorderThickness="1"
|
||||
IsVisible="{Binding IsMarkdownViewerVisible}">
|
||||
<md:MarkdownViewer Name="MarkdownViewer" MarkdownText="{Binding DraftArticle}" />
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
Grid.Row="2"
|
||||
Padding="8"
|
||||
BorderBrush="Gray"
|
||||
BorderThickness="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
IsVisible="{Binding !IsMarkdownViewerVisible}">
|
||||
<Grid
|
||||
Grid.Row="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
RowSpacing="10">
|
||||
|
|
@ -180,23 +166,13 @@
|
|||
PlaceholderText="Title"
|
||||
Text="{Binding DraftTitle, Mode=TwoWay}" />
|
||||
|
||||
|
||||
<AvaloniaEdit:TextEditor
|
||||
x:Name="DraftArticleTextEditor"
|
||||
<postitControls:MarkdownEditorControl
|
||||
Grid.Row="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
Document="{Binding DraftArticleDocument, Mode=TwoWay}"
|
||||
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
|
||||
ShowLineNumbers="True"
|
||||
WordWrap="True"
|
||||
BorderThickness="1"
|
||||
BorderBrush="LightGray"
|
||||
CornerRadius="5"
|
||||
Padding="5,5,5,15"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
TextInputOptions.Multiline="True"
|
||||
TextInputOptions.ReturnKeyType="Default"
|
||||
/>
|
||||
IsPreviewVisible="{Binding IsMarkdownViewerVisible, Mode=TwoWay}"
|
||||
MarkdownText="{Binding DraftArticle, Mode=TwoWay}"
|
||||
Document="{Binding DraftArticleDocument, Mode=TwoWay}" />
|
||||
|
||||
<Border x:Name="AttachmentsBorder"
|
||||
Grid.Row="3"
|
||||
|
|
@ -220,7 +196,6 @@
|
|||
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
|
|
|||
|
|
@ -4,72 +4,16 @@ using Avalonia.Controls;
|
|||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Controls.Platform;
|
||||
using Avalonia.Input;
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace PostIt.Views.Blogs;
|
||||
|
||||
public partial class BlogsPage : ContentPage
|
||||
{
|
||||
private IInputPane _inputPane;
|
||||
|
||||
public BlogsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnLoaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnLoaded(e);
|
||||
|
||||
// Récupère l'accès au TopLevel de la fenêtre/application
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel != null)
|
||||
{
|
||||
_inputPane = topLevel.InputPane;
|
||||
if (_inputPane != null)
|
||||
{
|
||||
// S'abonne aux changements d'état du clavier virtuel
|
||||
_inputPane.StateChanged += OnInputPaneStateChanged;
|
||||
|
||||
// Applique la configuration initiale si le clavier est déjà présent
|
||||
UpdateLayoutForKeyboard(_inputPane.OccludedRect.Height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnUnloaded(RoutedEventArgs e)
|
||||
{
|
||||
if (_inputPane != null)
|
||||
{
|
||||
_inputPane.StateChanged -= OnInputPaneStateChanged;
|
||||
}
|
||||
base.OnUnloaded(e);
|
||||
}
|
||||
|
||||
|
||||
private void UpdateLayoutForKeyboard(double keyboardHeight)
|
||||
{
|
||||
double visibleHeight = this.Bounds.Height - keyboardHeight;
|
||||
if (keyboardHeight > 0)
|
||||
{
|
||||
DraftArticleTextEditor.MaxHeight = visibleHeight;
|
||||
}
|
||||
else
|
||||
{
|
||||
DraftArticleTextEditor.MaxHeight = double.PositiveInfinity;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnInputPaneStateChanged(object? sender, InputPaneStateEventArgs e)
|
||||
{
|
||||
// e.EndRect contient le rectangle final du clavier virtuel
|
||||
// e.EndRect.Height vous donne sa hauteur exacte en pixels indépendants du périphérique (pixels logiques)
|
||||
UpdateLayoutForKeyboard(e.EndRect.Height);
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
|
|
|
|||
111
src/PostIt/PostIt/Views/Chat/ChatPage.axaml
Normal file
111
src/PostIt/PostIt/Views/Chat/ChatPage.axaml
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<ContentPage
|
||||
x:Class="PostIt.Views.Chat.ChatPage"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:md="using:MarkdownViewer.Core.Controls"
|
||||
xmlns:models="using:Yavsc.Blogspot"
|
||||
xmlns:postitControls="using:PostIt.Controls"
|
||||
xmlns:views="using:PostIt.Views.Chat"
|
||||
xmlns:vm="using:PostIt.ViewModels.Chat"
|
||||
x:DataType="vm:ChatViewModel"
|
||||
>
|
||||
<Design.DataContext>
|
||||
<vm:ChatViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto">
|
||||
|
||||
<Grid
|
||||
Grid.Row="1"
|
||||
Margin="0,0,0,10"
|
||||
ColumnDefinitions="*, 200">
|
||||
|
||||
<!-- Journal des flux de messages & notifications -->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto, *">
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
Margin="0,0,0,5"
|
||||
Orientation="Horizontal"
|
||||
Spacing="10">
|
||||
<TextBox
|
||||
Width="200"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding CurrentRoom}"
|
||||
PlaceholderText="Nom du salon" />
|
||||
<Button Command="{Binding JoinRoomCommand}" Content="Rejoindre Salon (Join)" />
|
||||
<!-- Choix de la salle en focus parmi celles jointes -->
|
||||
<ComboBox
|
||||
Width="180"
|
||||
VerticalAlignment="Center"
|
||||
ItemsSource="{Binding JoinedRooms}"
|
||||
PlaceholderText="Salle active"
|
||||
SelectedItem="{Binding SelectedRoom}" />
|
||||
</StackPanel>
|
||||
|
||||
<ListBox
|
||||
Grid.Row="1"
|
||||
BorderBrush="#E0E0E0"
|
||||
BorderThickness="1"
|
||||
ItemsSource="{Binding FocusedMessages}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" TextWrapping="Wrap" />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
|
||||
<!-- Liste des utilisateurs présents (MàJ via l'événement 'joint') -->
|
||||
<Border
|
||||
Grid.Column="1"
|
||||
Margin="10,0,0,0"
|
||||
BorderBrush="#E0E0E0"
|
||||
BorderThickness="1">
|
||||
<Grid RowDefinitions="Auto, *">
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="5"
|
||||
HorizontalAlignment="Center"
|
||||
FontWeight="Bold"
|
||||
Text="Membres Présents" />
|
||||
<ListBox
|
||||
Grid.Row="1"
|
||||
BorderThickness="0"
|
||||
ItemsSource="{Binding FocusedUsers}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
<!-- Saisie et envoi d'un message dans le salon joint -->
|
||||
<Grid
|
||||
Grid.Row="2"
|
||||
Margin="0,0,0,10"
|
||||
ColumnDefinitions="*, Auto">
|
||||
<TextBox
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
AcceptsReturn="False"
|
||||
IsEnabled="{Binding !!SelectedRoom}"
|
||||
PlaceholderText="Votre message..."
|
||||
Text="{Binding MessageToSend}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Command="{Binding SendMessageCommand}" Gesture="Enter" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Margin="10,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding SendMessageCommand}"
|
||||
Content="Envoyer" />
|
||||
</Grid>
|
||||
|
||||
<postitControls:StatusBar
|
||||
x:Name="StatusBar"
|
||||
Grid.Row="3"
|
||||
DataContext="{Binding Status}" />
|
||||
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
11
src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs
Normal file
11
src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia.Controls;
|
||||
namespace PostIt.Views.Chat;
|
||||
public partial class ChatPage : ContentPage
|
||||
{
|
||||
|
||||
public ChatPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:lang="using:Yavsc.Lang"
|
||||
x:Class="PostIt.Views.HomePage"
|
||||
x:DataType="vm:HomePageViewModel"
|
||||
Header="Home">
|
||||
|
|
@ -10,10 +11,15 @@
|
|||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12">
|
||||
<TextBlock Text="Welcome to the Home Page"
|
||||
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"/>
|
||||
|
||||
<Button Content="Les Salons de discussion"
|
||||
Command="{Binding OpenChat}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Open Blog Interface"
|
||||
Command="{Binding OpenBlogs}"
|
||||
HorizontalAlignment="Center"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
<DockPanel LastChildFill="True">
|
||||
<views:SessionStatusBanner
|
||||
x:Name="SessionBanner"
|
||||
DataContext="{Binding SessionStatus}"
|
||||
DockPanel.Dock="Bottom"/>
|
||||
<NavigationPage x:Name="NavRoot"/>
|
||||
</DockPanel>
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@
|
|||
x:Class="PostIt.Views.MainWindow"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Title="PostIt">
|
||||
<views:MainView x:Name="MainView" />
|
||||
<views:MainView />
|
||||
</Window>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ namespace Yavsc.Abstract.Chat
|
|||
{
|
||||
public static class ChatHubConstants
|
||||
{
|
||||
public const string HubGroupAuthenticated = "authenticated";
|
||||
public const string HubGroupAnonymous = "anonymous";
|
||||
public const string HubGroupCops= "cops";
|
||||
public const string HubGroupRomsPrefix = "room_";
|
||||
public const string HubGroupRoomsPrefix = "room_";
|
||||
public const int MaxChanelName = 255;
|
||||
|
||||
public const string HubGroupFollowingPrefix = "fol ";
|
||||
|
|
@ -21,6 +19,6 @@ namespace Yavsc.Abstract.Chat
|
|||
public const string HopWontKickOp = "Half operator cannot kick any operator";
|
||||
public const string LabAuthChatUser = "Authenticated chat user";
|
||||
public const string NoKickOnCop = "No, you won´t, you´ĺl never do kick a cop, it is the bad.";
|
||||
public const string LabNoJoinNoSend = "LabnoJoinNoSend";
|
||||
public const string LabNoJoinNoSend = "LabNoJoinNoSend";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Yavsc
|
|||
public const string BlogTagPath = "blogtag";
|
||||
public const string CirclePath = "circle";
|
||||
public const string CommentsPath = "blogcomments";
|
||||
public const string ChatHubPath = "chathub";
|
||||
|
||||
public static readonly Scope[] SiteScopes = {
|
||||
new Scope { Id = "profile", Description = "Your profile informations" },
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ using Yavsc.Models;
|
|||
using Yavsc;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Server.Hubs;
|
||||
|
||||
internal class Program
|
||||
{
|
||||
|
|
@ -65,7 +66,12 @@ internal class Program
|
|||
services.AddAuthentication("Bearer")
|
||||
.AddYavscJwtBearer(builder.Configuration);
|
||||
|
||||
services.AddSignalR();
|
||||
services.AddSignalR()
|
||||
// ChatRoomInfo et d'autres DTO de hub exposent des champs
|
||||
// publics (pas des propriétés) : STJ ne les sérialise pas
|
||||
// sans IncludeFields, et le client recevrait des objets vides.
|
||||
.AddJsonProtocol(options =>
|
||||
options.PayloadSerializerOptions.IncludeFields = true);
|
||||
services.AddSingleton<IConnexionManager, HubConnectionManager>();
|
||||
|
||||
// DbContextBuilder
|
||||
|
|
@ -114,13 +120,11 @@ internal class Program
|
|||
.UseRouting()
|
||||
.UseAuthentication()
|
||||
.UseAuthorization()
|
||||
.UseCors("default")
|
||||
;
|
||||
.UseCors("default");
|
||||
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
|
||||
app.MapDefaultControllerRoute();
|
||||
|
||||
|
||||
app.UseSession();
|
||||
app.MapHub<ChatHub>("/" + Constants.ChatHubPath);
|
||||
await app.RunAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@ namespace Yavsc.Controllers
|
|||
|
||||
public IActionResult SetUserName()
|
||||
{
|
||||
return View(new SetUserNameViewModel() { UserName = User.Identity.Name });
|
||||
return View(new SetUserNameViewModel() { UserName = User.GetUserName() });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
|
|
|
|||
|
|
@ -1048,13 +1048,12 @@ public static class HostingExtensions
|
|||
app.UseSwaggerUI();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseIdentityServer();
|
||||
app.UseAuthorization();
|
||||
app.UseCors("default");
|
||||
app.MapDefaultControllerRoute();
|
||||
app.MapStaticAssets(staticAssetsManifestPath);
|
||||
//app.MapRazorPages();
|
||||
app.MapHub<ChatHub>("/chatHub");
|
||||
|
||||
WorkflowHelpers.ConfigureBillingService();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1 @@
|
|||
.user-icon {
|
||||
max-width: 1.5em;
|
||||
max-height: 1.5em;
|
||||
}
|
||||
.user-icon{max-width:1.5em;max-height:1.5em}.card{padding:.5em}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
user-icon {
|
||||
.user-icon {
|
||||
max-width: 1.5em;
|
||||
max-height: 1.5em;
|
||||
}
|
||||
.card {
|
||||
padding: .5em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,29 @@ public static class ServiceExtensions
|
|||
options.ClaimsIssuer = authority;
|
||||
options.Audience = audiences[0];
|
||||
|
||||
// Sending the access token in the query string is required when using WebSockets or ServerSentEvents
|
||||
// due to a limitation in Browser APIs. We restrict it to only calls to the
|
||||
// SignalR hub in this code.
|
||||
// See https://docs.microsoft.com/aspnet/core/signalr/security#access-token-logging
|
||||
// for more information about security considerations when using
|
||||
// the query string to transmit the access token.
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
|
||||
// If the request is for our hub...
|
||||
var path = context.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrEmpty(accessToken) &&
|
||||
(path.StartsWithSegments("/hubs/chat")))
|
||||
{
|
||||
// Read the token out of the query string
|
||||
context.Token = accessToken;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
// Dev: every Yavsc resource service (Yavsc.Api, Yavsc.Blogs,
|
||||
// Yavsc.Org itself) validates JWTs against the OP that runs
|
||||
// on https://localhost:5001 with a self-signed dev cert.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
#pragma warning disable CS4014 // Dans la mesure où cet appel n'est pas attendu, l'exécution de la méthode actuelle continue avant la fin de l'appel
|
||||
|
||||
namespace Yavsc.Server.Hubs
|
||||
|
|
@ -36,6 +37,8 @@ namespace Yavsc.Server.Hubs
|
|||
using Yavsc.Models.Chat;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Services;
|
||||
|
||||
[Authorize]
|
||||
public partial class ChatHub : Hub, IDisposable
|
||||
{
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
|
|
@ -54,39 +57,30 @@ namespace Yavsc.Server.Hubs
|
|||
_localizer = stringLocalizerFactory.Create(typeof(ChatHub));
|
||||
|
||||
_cxManager = connexionManager;
|
||||
_cxManager.SetErrorHandler ((context, error) =>
|
||||
_cxManager.SetErrorHandler((context, error) =>
|
||||
{
|
||||
NotifyUser(NotificationTypes.Error, context, error);
|
||||
});
|
||||
_logger = loggerFactory.CreateLogger<ChatHub>();
|
||||
InputValidator = new HubInputValidator(_localizer) {
|
||||
|
||||
NotifyUser = async (type, target, msg) => await this.NotifyUser(type, target, msg) };
|
||||
}
|
||||
|
||||
void SetUserName(string cxId, string userName)
|
||||
InputValidator = new HubInputValidator(_localizer)
|
||||
{
|
||||
_cxManager.SetUserName(cxId, userName);
|
||||
|
||||
NotifyUser = async (type, target, msg) => await this.NotifyUser(type, target, msg)
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
bool isAuth = Context.User?.Identity?.IsAuthenticated ?? false;
|
||||
var user = Context.User;
|
||||
bool isCop = false;
|
||||
string userName = setUserName();
|
||||
if (isAuth)
|
||||
{
|
||||
string userName = user.GetUserName();
|
||||
|
||||
var group = isAuth ?
|
||||
ChatHubConstants.HubGroupAuthenticated : ChatHubConstants.HubGroupAnonymous;
|
||||
// Log ("Cx: " + group);
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, group);
|
||||
_logger.LogInformation(_localizer.GetString(ChatHubConstants.LabAuthChatUser));
|
||||
|
||||
var userId = _dbContext.Users.First(u => u.UserName == Context.User.Identity.Name).Id;
|
||||
var userId = _dbContext.Users.First(u => u.UserName == userName).Id;
|
||||
|
||||
await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.Connected, userName, null);
|
||||
isCop = Context.User.IsInMsRole(Constants.AdminGroupName) ;
|
||||
isCop = Context.User.IsInMsRole(Constants.AdminGroupName);
|
||||
if (isCop)
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupCops);
|
||||
|
|
@ -96,57 +90,36 @@ namespace Yavsc.Server.Hubs
|
|||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupFollowingPrefix + uid);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupAnonymous);
|
||||
}
|
||||
_cxManager.OnConnected(Context.ConnectionId, isCop);
|
||||
_cxManager.OnConnected(Context.ConnectionId, userName, isCop);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
string setUserName(string queryUname = "anon")
|
||||
{
|
||||
if (Context.User != null)
|
||||
if (Context.User.Identity.IsAuthenticated)
|
||||
{
|
||||
SetUserName(Context.ConnectionId, Context.User.Identity.Name);
|
||||
return Context.User.Identity.Name;
|
||||
}
|
||||
anonymousSequence++;
|
||||
var anonymousName = $"{ChatHubConstants.AnonymousUserNamePrefix}{queryUname}{anonymousSequence}";
|
||||
SetUserName(Context.ConnectionId, anonymousName);
|
||||
return anonymousName;
|
||||
}
|
||||
|
||||
static long anonymousSequence = 0;
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception ex)
|
||||
{
|
||||
string userName = Context.User?.Identity.Name;
|
||||
string userName = Context.User?.GetUserName();
|
||||
if (userName != null)
|
||||
{
|
||||
var user = _dbContext.Users.FirstOrDefault(u => u.UserName == userName);
|
||||
if (user != null)
|
||||
{
|
||||
var userId = user.Id;
|
||||
await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.DisConnected, userName, null);
|
||||
}
|
||||
|
||||
_cxManager.OnDisconnected(Context.ConnectionId);
|
||||
}
|
||||
await base.OnDisconnectedAsync(ex);
|
||||
}
|
||||
|
||||
|
||||
public async Task Nick(string nickName)
|
||||
/// <summary>
|
||||
/// Renvoie le login résolu côté serveur (claim <c>name</c> de
|
||||
/// l'organisation Yavsc.Org, via <see cref="UserHelpers.GetUserName"/>).
|
||||
/// Permet au client d'afficher l'identité réellement authentifiée,
|
||||
/// sans jamais faire confiance à un pseudo fourni par le client.
|
||||
/// </summary>
|
||||
public string WhoAmI()
|
||||
{
|
||||
if (!InputValidator.ValidateUserName(nickName)) return;
|
||||
|
||||
var candidate = "?" + nickName;
|
||||
if (_cxManager.IsConnected(candidate))
|
||||
{
|
||||
await NotifyUser(NotificationTypes.ExistingUserName, nickName, "aborting");
|
||||
return;
|
||||
}
|
||||
_cxManager.SetUserName( Context.ConnectionId, candidate);
|
||||
return Context.User?.GetUserName() ?? string.Empty;
|
||||
}
|
||||
|
||||
bool IsPresent(string roomName, string userName)
|
||||
|
|
@ -163,7 +136,7 @@ namespace Yavsc.Server.Hubs
|
|||
return null;
|
||||
}
|
||||
|
||||
var roomGroupName = ChatHubConstants.HubGroupRomsPrefix + roomName;
|
||||
var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
|
||||
var user = _cxManager.GetUserName(Context.ConnectionId);
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, roomGroupName);
|
||||
ChatRoomInfo channelInfo;
|
||||
|
|
@ -172,12 +145,18 @@ namespace Yavsc.Server.Hubs
|
|||
_logger.LogInformation($"Joining");
|
||||
channelInfo = _cxManager.Join(roomName, Context.ConnectionId);
|
||||
await Clients.Group(roomGroupName).SendAsync("notifyRoom", NotificationTypes.UserJoin, roomName, user);
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation($"already present");
|
||||
// in case in an additional connection,
|
||||
// one only send info on room without
|
||||
// warning any other user.
|
||||
_cxManager.TryGetChanInfo(roomName, out channelInfo);
|
||||
if (!_cxManager.TryGetChanInfo(roomName, out channelInfo))
|
||||
{
|
||||
_logger.LogError($"Failed to retrieve channel info for room: {roomName}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation($"returning channel info");
|
||||
|
|
@ -185,10 +164,9 @@ namespace Yavsc.Server.Hubs
|
|||
return channelInfo;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
public void Register(string room)
|
||||
{
|
||||
if (!InputValidator.ValidateRoomName(room)) return ;
|
||||
if (!InputValidator.ValidateRoomName(room)) return;
|
||||
var existent = _dbContext.ChatRoom.Any(r => r.Name == room);
|
||||
if (existent)
|
||||
{
|
||||
|
|
@ -212,27 +190,25 @@ namespace Yavsc.Server.Hubs
|
|||
_dbContext.SaveChanges(user.Id);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
public void KickBan(string roomName, string userName, string reason)
|
||||
{
|
||||
if (!InputValidator.ValidateRoomName(roomName)) return ;
|
||||
if (!InputValidator.ValidateUserName(userName)) return ;
|
||||
if (!InputValidator.ValidateRoomName(roomName)) return;
|
||||
if (!InputValidator.ValidateUserName(userName)) return;
|
||||
if (!InputValidator.ValidateReason(reason)) return;
|
||||
Kick(roomName, userName, reason);
|
||||
Ban(roomName, userName, reason);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
public async Task Kick(string roomName, string userName, string reason)
|
||||
{
|
||||
if (!InputValidator.ValidateRoomName(roomName)) return ;
|
||||
if (!InputValidator.ValidateUserName(userName)) return ;
|
||||
if (!InputValidator.ValidateRoomName(roomName)) return;
|
||||
if (!InputValidator.ValidateUserName(userName)) return;
|
||||
if (!InputValidator.ValidateReason(reason)) return;
|
||||
ChatRoomInfo channelInfo;
|
||||
var roomGroupName = ChatHubConstants.HubGroupRomsPrefix + roomName;
|
||||
var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
|
||||
if (_cxManager.TryGetChanInfo(roomName, out channelInfo))
|
||||
{
|
||||
if (!_cxManager.IsPresent(roomName,userName))
|
||||
if (!_cxManager.IsPresent(roomName, userName))
|
||||
{
|
||||
NotifyErrorToCallerInRoom(roomName, $"{userName} was not found in {roomName}.");
|
||||
return;
|
||||
|
|
@ -242,42 +218,42 @@ namespace Yavsc.Server.Hubs
|
|||
if (!_cxManager.Kick(Context.ConnectionId, userName, roomName, reason)) return;
|
||||
}
|
||||
var cxIds = _cxManager.GetConnexionIds(userName);
|
||||
if (cxIds!=null) foreach(var cx in cxIds)
|
||||
if (cxIds != null) foreach (var cx in cxIds)
|
||||
await Groups.RemoveFromGroupAsync(cx, roomGroupName);
|
||||
await Clients.Group(roomGroupName).SendAsync("notifyRoom", NotificationTypes.Kick, roomName, $"{userName}: {reason}");
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
public void Ban(string roomName, string userName, string reason)
|
||||
{
|
||||
if (!InputValidator.ValidateRoomName(roomName)) return ;
|
||||
if (!InputValidator.ValidateUserName(userName)) return ;
|
||||
if (!InputValidator.ValidateRoomName(roomName)) return;
|
||||
if (!InputValidator.ValidateUserName(userName)) return;
|
||||
if (!InputValidator.ValidateReason(reason)) return;
|
||||
var cxIds = _cxManager.GetConnexionIds(userName);
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[Authorize(Constants.AdminGroupName)]
|
||||
public void GLine(string userName, string reason)
|
||||
{
|
||||
if (!InputValidator.ValidateUserName(userName)) return ;
|
||||
if (!InputValidator.ValidateUserName(userName)) return;
|
||||
if (!InputValidator.ValidateReason(reason)) return;
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void Part(string roomName, string reason)
|
||||
{
|
||||
if (!InputValidator.ValidateRoomName(roomName)) return ;
|
||||
if (!InputValidator.ValidateRoomName(roomName)) return;
|
||||
if (!InputValidator.ValidateReason(reason)) return;
|
||||
if (_cxManager.Part(Context.ConnectionId, roomName, reason))
|
||||
{
|
||||
var roomGroupName = ChatHubConstants.HubGroupRomsPrefix + roomName;
|
||||
var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
|
||||
var group = Clients.Group(roomGroupName);
|
||||
var userName = _cxManager.GetUserName(Context.ConnectionId);
|
||||
group.SendAsync("notifyRoom", NotificationTypes.UserPart, roomName, $"{userName}: {reason}");
|
||||
Groups.RemoveFromGroupAsync(Context.ConnectionId, roomGroupName);
|
||||
}
|
||||
else {
|
||||
else
|
||||
{
|
||||
_logger.LogError("Could not part");
|
||||
}
|
||||
}
|
||||
|
|
@ -291,16 +267,18 @@ namespace Yavsc.Server.Hubs
|
|||
public async Task Send(string roomName, string message)
|
||||
{
|
||||
_logger.LogInformation($"Send {roomName} {message}");
|
||||
if (!InputValidator.ValidateRoomName(roomName)) {
|
||||
if (!InputValidator.ValidateRoomName(roomName))
|
||||
{
|
||||
_logger.LogError($"Invalid roomName : {roomName}");
|
||||
return ;
|
||||
return;
|
||||
}
|
||||
if (!InputValidator.ValidateMessage(message)) {
|
||||
if (!InputValidator.ValidateMessage(message))
|
||||
{
|
||||
_logger.LogError($"Invalid message : {message}");
|
||||
return ;
|
||||
return;
|
||||
}
|
||||
var groupName = ChatHubConstants.HubGroupRomsPrefix + roomName;
|
||||
ChatRoomInfo channelInfo ;
|
||||
var groupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
|
||||
ChatRoomInfo channelInfo;
|
||||
if (!_cxManager.TryGetChanInfo(roomName, out channelInfo))
|
||||
{
|
||||
_logger.LogError($"No such room : {roomName}");
|
||||
|
|
@ -317,7 +295,7 @@ namespace Yavsc.Server.Hubs
|
|||
return;
|
||||
}
|
||||
var group = Clients.Group(groupName);
|
||||
var msg = new { Name = userName, Room = roomName, Message = message};
|
||||
var msg = new { Name = userName, Room = roomName, Message = message };
|
||||
await group.SendAsync("ReceiveMessage", msg);
|
||||
}
|
||||
|
||||
|
|
@ -332,7 +310,6 @@ namespace Yavsc.Server.Hubs
|
|||
await Clients.Caller.SendAsync("notifyUserInRoom", type, room, message);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
public async Task SendPV(string userName, string message)
|
||||
{
|
||||
// Authorized code
|
||||
|
|
@ -342,17 +319,17 @@ namespace Yavsc.Server.Hubs
|
|||
if (!InputValidator.ValidateUserName(userName))
|
||||
{
|
||||
_logger.LogError($"Invalid username : {userName}");
|
||||
return ;
|
||||
return;
|
||||
}
|
||||
if (!InputValidator.ValidateMessage(message))
|
||||
{
|
||||
_logger.LogError($"Invalid message : {message}");
|
||||
return ;
|
||||
return;
|
||||
}
|
||||
_logger.LogInformation($"Message form is validated.");
|
||||
var identityUserName = Context.User.GetUserName();
|
||||
|
||||
if (userName[0] != '?' && Context.User!=null)
|
||||
if (userName[0] != '?' && Context.User != null)
|
||||
if (!Context.User.IsInMsRole(Constants.AdminGroupName))
|
||||
{
|
||||
|
||||
|
|
@ -373,7 +350,7 @@ namespace Yavsc.Server.Hubs
|
|||
|
||||
_logger.LogInformation("getting cx id´s");
|
||||
var cxIds = _cxManager.GetConnexionIds(userName);
|
||||
if (cxIds==null || cxIds.Count()==0)
|
||||
if (cxIds == null || cxIds.Count() == 0)
|
||||
_logger.LogError($"No such connected user : {userName}");
|
||||
else foreach (var connectionId in cxIds)
|
||||
{
|
||||
|
|
@ -385,18 +362,18 @@ namespace Yavsc.Server.Hubs
|
|||
}
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
public async Task SendStream(string connectionId, long streamId, string message)
|
||||
{
|
||||
// Authorized code
|
||||
Debug.Assert(Context.User != null);
|
||||
Debug.Assert(Context.User.Identity != null);
|
||||
if (!InputValidator.ValidateMessage(message)) return;
|
||||
var sender = Context.User.Identity.Name;
|
||||
var sender = Context.User.GetUserName();
|
||||
var cli = Clients.Client(connectionId);
|
||||
await cli.SendAsync("addStreamInfo", sender, streamId, message);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS4014 // Dans la mesure où cet appel n'est pas attendu, l'exécution de la méthode actuelle continue avant la fin de l'appel
|
||||
|
||||
#pragma warning restore CS4014
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@ using Yavsc.ViewModels.Chat;
|
|||
namespace Yavsc.Services
|
||||
{
|
||||
public interface IConnexionManager {
|
||||
void SetUserName(string cxId, string userName);
|
||||
|
||||
string GetUserName (string cxId);
|
||||
void OnConnected(string cxId, bool isCop);
|
||||
void OnConnected(string cxId, string userName, bool isCop);
|
||||
bool IsConnected(string candidate);
|
||||
|
||||
void OnDisconnected (string cxId);
|
||||
|
|
|
|||
|
|
@ -53,30 +53,15 @@ namespace Yavsc.Services
|
|||
_localizer = stringLocFactory.Create(typeof(HubConnectionManager));
|
||||
}
|
||||
|
||||
public void SetUserName(string cxId, string userName)
|
||||
public void OnConnected(string cxId, string userName, bool isCop)
|
||||
{
|
||||
string oldUname;
|
||||
if (ChatUserNames.TryGetValue(cxId, out oldUname))
|
||||
{
|
||||
// this is a rename
|
||||
if (oldUname == userName) return;
|
||||
ChatCxIds[userName] = ChatCxIds[oldUname];
|
||||
ChatCxIds[oldUname] = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// this is a connexion
|
||||
ChatCxIds[userName] = new List<string>() { cxId };
|
||||
}
|
||||
ChatUserNames[cxId] = userName;
|
||||
}
|
||||
// Username must have been set before calling this method.
|
||||
public void OnConnected(string cxId, bool isCop)
|
||||
{
|
||||
var username = ChatUserNames[cxId];
|
||||
if (!IsConnected(username))
|
||||
ChatRoomPresence[username] = new List<string>();
|
||||
_isCop[username] = isCop;
|
||||
ChatCxIds.AddOrUpdate(userName,
|
||||
_ => new List<string> { cxId },
|
||||
(_, list) => { lock (list) { if (!list.Contains(cxId)) list.Add(cxId); } return list; });
|
||||
if (!IsConnected(userName))
|
||||
ChatRoomPresence[userName] = new List<string>();
|
||||
_isCop[userName] = isCop;
|
||||
}
|
||||
|
||||
public bool IsConnected(string candidate)
|
||||
|
|
|
|||
Loading…
Reference in a new issue