Compare commits

..
251 changed files with 1010 additions and 13072 deletions

View file

@ -27,7 +27,7 @@ jobs:
build: build:
runs-on: docker runs-on: docker
container: container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
steps: steps:
- name: Clone yavsc - name: Clone yavsc
run: | run: |
@ -56,11 +56,3 @@ jobs:
--report-xunit \ --report-xunit \
--report-xunit-html \ --report-xunit-html \
--report-xunit-html-filename "test-results.html" --report-xunit-html-filename "test-results.html"
TEST_EXIT_CODE=$?
echo "Test exit code: $TEST_EXIT_CODE"
if [ $TEST_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
exit $TEST_EXIT_CODE
else
echo "✅ All tests passed successfully."
fi

View file

@ -52,7 +52,7 @@ jobs:
release: release:
runs-on: docker runs-on: docker
container: container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
steps: steps:
- name: Clone du repo au tag demandé - name: Clone du repo au tag demandé
env: env:

View file

@ -1,3 +0,0 @@
# Conventions du projet
- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer.

View file

@ -8,7 +8,8 @@ on:
jobs: jobs:
production-deploy: production-deploy:
runs-on: ubuntu-26.04 runs-on: ubuntu-latest
steps: steps:
# 1. Récupération du code # 1. Récupération du code
- name: Check out the repo - name: Check out the repo
@ -24,24 +25,8 @@ jobs:
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 10.0.x
- name: Workloads restore
run: dotnet workload restore
- name: Test - name: Test
env: run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android"
TEST_FAILED: ""
run: make test || export TEST_FAILED=$?
- uses: actions/upload-artifact@v7
with:
name: my-artifact
path: test-reports/**/*
- name: Fail if tests failed
if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }}
run: exit $TEST_FAILED
# 4. Build et Push de l'image de production finale # 4. Build et Push de l'image de production finale
- name: Build and push production image - name: Build and push production image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7

6
.gitignore vendored
View file

@ -45,7 +45,7 @@ DataDir/
# gitleaks scan output (local secret-scan report, not for the repo) # gitleaks scan output (local secret-scan report, not for the repo)
/gitleaks-report.json /gitleaks-report.json
/test-report.html
/test-report.xml
# Exclude test reports (local test report, not for the repo)
/test-reports
/doc/**/.*.md

8
.vscode/launch.json vendored
View file

@ -22,9 +22,11 @@
}, },
{ {
"name": "Android Debug", "name": "Android Debug",
"type": "maui", "type": "mono",
"request": "launch", "preLaunchTask": "run-debug-android",
"project": "${workspaceFolder}/src/PostIt/PostIt.Android/PostIt.Android.csproj" "request": "attach",
"address": "localhost",
"port": 55555
}, },
{ {
"name": "Android Attach - Debug", "name": "Android Attach - Debug",

View file

@ -45,10 +45,5 @@
] ]
} }
}, },
"dotnet.defaultSolution": "yavsc.sln", "dotnet.defaultSolution": "yavsc.sln"
"axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln",
"chat.tools.terminal.autoApprove": {
"dotnet build": true,
"dotnet test": true
}
} }

6
.vscode/tasks.json vendored
View file

@ -4,13 +4,9 @@
"tasks": [ "tasks": [
{ {
"label": "build-debug-android", "label": "run-debug-android",
"command": "dotnet", "command": "dotnet",
"detail": "Build the PostIt Android project in debug mode",
"type": "shell", "type": "shell",
"runOptions": {
},
"problemMatcher": ["$msCompile"], "problemMatcher": ["$msCompile"],
"options": { "options": {
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android", "cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",

View file

@ -1,325 +1,5 @@
# Changelog # Changelog
## [1.0.8-rc25] - unstable
### Added
* [Yavsc.Api] Notification du performer à la création d'un rendez-vous
depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org.
* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature
client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt
et transmise par e-mail et SignalR.
* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me`
pour exposer les notifications publiques et ciblées à PostIt sur la
Home page ; les notifications d'administration restent filtrées.
* [PostIt] Affichage des notifications sur la page d'accueil, avec
actualisation, indicateur de chargement et message d'erreur. Les
notifications publiques sont accessibles sans connexion et la liste
est rechargée lors d'un changement de session.
### Changed
* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ;
les liens provisoires des nouveaux billets sont résolus au premier
enregistrement. Save conserve l'édition ouverte et est mis en valeur
lorsque le titre, le corps ou les pièces jointes sont modifiés.
* [Projet] Instructions Copilot persistantes : mise à jour du changelog
par défaut à chaque commit, avec justification explicite des exceptions.
### Fixed
* [PostIt.Tests] Les tests du bandeau de session utilisent le bandeau
réellement monté dans la fenêtre headless, au lieu d'un contrôle détaché.
Les fenêtres sont fermées après chaque test et les quatre boutons
de navigation sont vérifiés.
* [PostIt] Les liens Markdown des pièces jointes utilisent désormais
le même filtre de nom que l'upload serveur, avant l'encodage URL.
Le libellé conserve le nom original, notamment ses accents.
* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés
dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour
éviter les fragments d'URL et permettre le téléchargement via les
fichiers statiques, notamment pour les noms accentués.
* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont
désormais enregistrés côté serveur pour les requêtes authentifiées :
le middleware de télémétrie est maintenant branché après
l'authentification, ce qui permet de comptabiliser `ApiCalls` et
`BandwidthMb` au lieu d'enregistrer des zéros.
## [1.0.8-rc24] - unstable
### Added
* [PostIt] Reconnection automatique aux salons de discussion au reconnect
### Changed
* [PostIt] La liste des billets et l'éditeur Markdown se partagent
l'espace disponible et défilent séparément ; un séparateur permet
de redimensionner les deux zones.
### Fixed
* [PostIt] Défilement des articles longs en édition et en aperçu :
suppression de la limite de hauteur qui masquait le contenu Markdown
et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless
couvrent les barres automatiques, le redimensionnement et le
défilement indépendant de la liste.
## [1.0.8-rc23] - unstable
### Added
* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la
liste, mis à jour lors de la publication ou du retour en brouillon.
* [PostIt] Gestion native du compte inspirée de `/Manage` : modification
du nom d'utilisateur, du nom complet et de l'adresse, téléversement
d'avatar, confirmation e-mail, préférence de courriel mensuel,
création ou changement de mot de passe et activation/désactivation
de l'authentification à deux facteurs.
* [PostIt][Yavsc.Api] Sélection d'un agenda Google, gestion séparée des
comptes bancaires, dissociation des connexions externes et suppression
du compte avec confirmation du nom d'utilisateur.
* [PostIt] Configuration du profil professionnel et des activités,
navigation vers les cercles et les publications depuis le compte.
* [PostIt][Yavsc.Api] Administration des utilisateurs avec recherche
dans les métadonnées du profil et consultation de la consommation
de ressources.
* [Yavsc.Server][Yavsc.Api][Yavsc.Blogs] Suivi de la consommation de
ressources, API de synthèse et migration `ResourceUsageRecords`.
* [Tests] Couverture des parcours profil et professionnel, de l'API de
consommation de ressources et du renouvellement des jetons :
rotation à usage unique, appels simultanés, réponses 401 concurrentes
et rejet d'un jeton de renouvellement révoqué.
### Changed
* [PostIt] Réorganisation de la page de compte en sections de profil,
sécurité et préférences ; actions de modification désactivées tant
que le profil n'est pas chargé ou qu'une opération est en cours.
* [PostIt] Utilisation de `PlaceholderText` à la place de `Watermark`
dans les pages de profil et de configuration professionnelle.
* [Yavsc.Api] `GET account/me` renvoie le DTO partagé attendu par
PostIt, avec l'état de sécurité, les connexions externes, les comptes
bancaires et les compteurs du compte.
### Fixed
* [Yavsc.Api] Enregistrement des services ASP.NET Identity et des stores
EF nécessaires à `UserManager<ApplicationUser>` : leur absence
empêchait l'activation du contrôleur de compte et provoquait une 500.
* [Yavsc.Api] Chargement du profil : réponse adaptée lorsqu'un jeton
ne contient pas d'identifiant utilisateur ou que le compte n'existe
plus, au lieu d'une exception serveur.
* [PostIt] Synchronisation de tous les chemins de renouvellement des
jetons, y compris après une 401 et au démarrage : les appels
concurrents ne réutilisent plus le même `refresh_token`. Un rejet
`invalid_grant` efface la session et invite à se reconnecter.
* [PostIt] Effacement des informations obsolètes après un échec de
rechargement du profil et normalisation des valeurs de calendrier
lors de la sauvegarde.
* [PostIt][Yavsc.Api] Préservation de l'identité du profil professionnel
lors de son enregistrement.
* [Yavsc.Api] Les coordonnées bancaires ne sont plus modifiées à partir
d'un résumé agrégé : opérations dédiées avec contrôle de propriété
et validation de longueur des champs.
### Known limitations
* [PostIt] L'ajout d'un nouveau fournisseur OAuth et la gestion des
crédits ne sont pas encore disponibles dans l'interface native.
## [1.0.8-rc22] - unstable
### 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
La saisie du billet est maintenant plutôt possible,
en petit écran.
## [1.0.8-rc18] - unstable
* [PostIt] Page "About" avec lines vers le dépôt et les issues Forgejo
* Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout
(arbre de stockage indexé par le login, pas par le `sub`), boutons
d'action des listes réparés (binding `$parent`), revendication `name`
enfin présente dans les access tokens (branchement du `ProfileService`),
et négociation HTTP/3 côté client PostIt.
### Added
* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc :
`DefaultRequestVersion = 3.0`, `DefaultVersionPolicy =
RequestVersionOrLower` sur le `HttpClient` partagé de `YavscApiClient`,
avec repli h2/h1.1 où QUIC n'est pas disponible (WASM fetch, Android/
desktop sans libmsquic).
* [Tests] `MyFilesPageHeadlessTests` : monte `MyFilesPage` via
`NavigationPage` + `Window.Show()` et vérifie que chaque bouton de
ligne (Télécharger / Supprimer) a sa `Command` liée, est armé
(`CanExecute`) et déclenche l'appel API au clic.
* [Tests] `Remoting.PasswordToken_carries_name_claim_equal_to_login` :
mint un vrai JWT via le token endpoint IdentityServer (password grant,
`openid profile`) et asserte que l'access token porte `name`=login et
que `GetUserName()` résout vers ce login.
* [Tests] `FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login` :
découple `sub` et login, téléverse sous le login, et asserte que
`GET /api/v1/fs` retourne le fichier (et que le répertoire nommé d'après
le `sub` n'existe pas).
### Changed
* [Yavsc.Org] Branchement du `ProfileService` custom
(`AddProfileService<ProfileService>()` après
`AddAspNetIdentity<ApplicationUser>()`). La classe était du code mort :
IdentityServer8 retombait sur son profile service par défaut, qui
n'émet que les claims listés dans les `UserClaims` des API resources.
`name` n'en faisait jamais partie, donc tout access token était sans
`name` et `GetUserName()` résolvait à `null` sur les hosts ressources
(Blogs/Api) — cause du 500 sur l'upload MyFiles et du
`User.Identity.Name` injoignable. Le service émet désormais
`name`=`user.UserName`, `email` et les rôles pour tout client dont
les `AllowedScopes` contiennent `profile`, indépendamment du caller.
* [Yavsc.Server] `ServiceExtensions` : `NameClaimType =
JwtClaimTypes.Name`, `MapInboundClaims = false` (conserver les noms
courts de claims tels qu'émis).
* [Yavsc.Blogs] `FileSystemHelpers.EnsureDestinationDirectory` utilise
`GetUserName()` (claim `name`) au lieu de `User.Identity.Name` pour
la racine de stockage par utilisateur.
* [Tests] `WebServerFixture` : `AccessTokenType = 0` (Jwt dans IS8 ;
1 = Reference, l'inverse de Duende) ; `openid`+`profile` sur le client
de test ; réensemencement des identity resources openid/profile dans
`ReseedAuthTestData` (un `ResetAndMigrateDatabase` vide le store
in-memory partagé et les laissait absentes → `invalid_scope`).
* [Tests] `TestTokenIssuer.Issue` gagne un paramètre `name` optionnel
(défaut = subject) pour refléter la forme production où
`sub` (GUID) ≠ `name` (login).
* [Tests] `BillingServiceTests` : `[Collection("Yavsc Server")]` pour
sérialiser avec le build d'hôte qui mute les registres statiques
`BillingService`/`Config` (race parallèle pré-existante).
### Fixed
* [Yavsc.Blogs] La liste `GET /api/v1/fs` cherchait sous
`{Blog}/{sub-GUID}` (`User.GetUserId()`), alors que l'arbre de
stockage perso est indexé par le login partout ailleurs
(`EnsureDestinationDirectory` écrit sous `{Blog}/{name-claim}`,
le endpoint de téléchargement et les avatars utilisent
`user.UserName`). Une fois l'upload corrigé, les fichiers
atterrissaient sous `{Blog}/{login}` tandis que la liste cherchait
sous `{Blog}/{GUID}` — un arbre vide — d'où la page PostIt
« Mes fichiers » qui ne listait rien. `GetDir` résout désormais
l'utilisateur en base et liste sous `user.UserName`.
`UploadedFile.OwnerId` reste `sub`, donc `EnrichWithFileIds`
interpole toujours par `uid`.
* [PostIt] Boutons d'action des lignes de liste grisés et inactifs au
clic (Télécharger / Supprimer / Joindre sur MyFiles ; download/detach
sur les listes de pièces jointes de `BillingQueryDetailsPage` et
`EstimateEditionPage`). Le binding `$parent[vm:XxxViewModel].Cmd`
cherchait un ancêtre CONTRÔLE de type `XxxViewModel` — un viewmodel,
pas un contrôle — donc résolvait à `null` et le `Button` restait
désactivé (grisé) et inerte au clic. Corrigé en
`$parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd` (syntaxe
déjà en vigueur dans `CirclesPage` / `EstimateListPage`).
* [PostIt] Les messages de la barre de statut (`StatusBar`) n'étaient
pas sélectionnables/copiables : le texte était un `TextBlock` (non
sélectionnable dans Avalonia). Remplacé par `SelectableTextBlock`.
* [Yavsc.Blogs] La suppression d'un fichier (`DELETE api/v1/fs/{name}`)
renvoyait un 40x alors que le fichier était bien supprimé du disque.
`DeleteUserDirOrFile` (et `DeleteUserFile`) lisaient
`FileInfo.Length` **après** `fi.Delete()` ; or `FileInfo.Length`
lève `FileNotFoundException` une fois le fichier disparu — le `catch`
du contrôleur transformait cette exception en 400, d'où le message
perçu « le fichier n'existe pas sur le serveur » tandis que la
suppression avait réussi. La taille est désormais capturée avant la
suppression.
* PostIt default settings
## [1.0.8-rc17] - unstable ## [1.0.8-rc17] - unstable
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt + Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
@ -829,8 +509,6 @@ the ACL now comes along with the article,
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD [Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16 [1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16
[1.0.8-rc17]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc16...1.0.8-rc17
[1.0.8-rc18]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc17...1.0.8-rc18
[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15 [1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 [1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7

View file

@ -24,24 +24,41 @@
# ===================================================================== # =====================================================================
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
ARG BUILD_ENV_TAG=debian13-dotnet10-android36-jdk21-v2 ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1
FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env
WORKDIR /src WORKDIR /src
# (1) Manifest + fichiers de projet pour le cache de restore
COPY *.sln ./
COPY Directory.*.props ./
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
COPY src/cli/*.csproj ./src/cli/
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# (2) Tout le code source
COPY . . COPY . .
# (4) Restore
RUN dotnet restore RUN dotnet restore
# Build des trois services web principaux # (5) Build des trois services web principaux
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
# Build APK Android (utilisé par le workflow docker-publish-android). # (6) Build APK Android (utilisé par le workflow docker-publish-android).
ARG ANDROID_TARGET_RID=android-arm64 ARG ANDROID_TARGET_RID=android-arm64
RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID} RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID}
# Publish des trois services web (les artifacts sont consommés par # (7) Publish des trois services web (les artifacts sont consommés par
# les stages publish-org / publish-api / publish-blogs ci-dessous). # les stages publish-org / publish-api / publish-blogs ci-dessous).
RUN mkdir -p /app/publish RUN mkdir -p /app/publish
RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org

View file

@ -1,12 +1,36 @@
# Utilisation de l'image globale pour le build et l'exécution # Utilisation de l'image globale pour le build et l'exécution
FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env
WORKDIR /src WORKDIR /src
# 1. Copie de la solution # 1. Copie de la solution
COPY *.sln ./
COPY Directory.*.props ./
# 2. Copie de l'intégralité des fichiers de projets (.csproj) pour la restauration
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
COPY src/cli/*.csproj ./src/cli/
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
# Projets associés à PostIt
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# 4. Copie de l'intégralité du code source
COPY . . COPY . .
RUN dotnet workload restore
# 4. Restauration des dépendances pour tous les projets # 4. Restauration des dépendances pour tous les projets
RUN dotnet restore RUN dotnet restore
# 5. Copie de la totalité du code source (filtrée par votre .dockerignore)
COPY . .
# 6. Compilation du serveur Web principal # 6. Compilation du serveur Web principal
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly

View file

@ -5,6 +5,8 @@ DESTDIR=/tmp/yavsc
APP_PATH=srv/www/yavsc APP_PATH=srv/www/yavsc
APP_FULL_PATH=$(DESTDIR)/$(APP_PATH) APP_FULL_PATH=$(DESTDIR)/$(APP_PATH)
include .env
all: all:
dotnet build --nologo dotnet build --nologo
@ -19,29 +21,7 @@ src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
test: test:
ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \ ASPNETCORE_ENVIRONMENT=Development dotnet test
--report-xunit-html \
--results-directory "test-reports"
test-postit:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "postit-test-results.html" \
--project src/PostIt/PostIt.Tests/PostIt.Tests.csproj
backend-tests: Blogs-backend-test Org-backend-test Api-backend-test
Api-backend-test:
Blogs-backend-test:
Org-backend-test:
%-backend-test:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "$*-backend-test-results.html" \
--project src/test/Yavsc.$*.Tests/Yavsc.$*.Tests.csproj
watch: watch:
dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj

View file

@ -232,12 +232,6 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d
* ~/Views/Home/Index.cshtml * ~/Views/Home/Index.cshtml
* ~/Views/Home/About.cshtml * ~/Views/Home/About.cshtml
# 👥 Crédits & Co-auteurs
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 # 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> 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>

View file

@ -14,7 +14,7 @@ reinstall: copy-binaries
@echo "💯 backend started" @echo "💯 backend started"
reinstall-prod: reinstall-prod:
make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
generated/$(APP_INSTALL_PREFIX)yavscOrg.service: generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service: generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:

View file

@ -8,7 +8,7 @@
"CorsAllowedOrigins": [ "CorsAllowedOrigins": [
"https://preprod-api.$DOMAIN" "https://preprod-api.$DOMAIN"
], ],
"UserFilesRoot": "files" "Blog": "blog"
}, },
"ConnectionStrings": { "ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;" "YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"

View file

@ -30,7 +30,7 @@
"EMail": "paul@pschneider.fr" "EMail": "paul@pschneider.fr"
}, },
"Quota": 200000000, "Quota": 200000000,
"UserFilesRoot": "files", "Blog": "blog",
"Avatars": "avatars", "Avatars": "avatars",
"Bills": "bills", "Bills": "bills",
"TempDir": "temp", "TempDir": "temp",

View file

@ -57,25 +57,5 @@ server {
proxy_read_timeout 100s; proxy_read_timeout 100s;
proxy_buffering off; 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; include server-status.conf;
} }

View file

@ -126,20 +126,6 @@ le DI est construit. Ordre, dans cet ordre :
> à cette table, *ne pas* ajouter de registration pour > à cette table, *ne pas* ajouter de registration pour
> `Settings` ailleurs que la ligne `AddSingleton(settings)`. > `Settings` ailleurs que la ligne `AddSingleton(settings)`.
## Défilement de la page des billets
La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête
et les commandes restent visibles, tandis que la liste des billets et
l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet
de modifier cette répartition. La liste et l'article défilent séparément.
Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ;
l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter
la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir
dépasser le viewport pour que les barres automatiques s'activent.
Ne pas placer cette grille dans un `StackPanel` vertical ou un
`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée.
## Navigation ## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"` Le host de navigation est un `NavigationPage x:Name="NavRoot"`

View file

@ -26,7 +26,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: web-runtime target: web-runtime
args: args:
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 BUILD_ENV_TAG: debian12-dotnet10-android36-v1
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -53,7 +53,9 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
network_mode: host networks:
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# # Indispensable pour que Yavsc.Org (IdentityServer8) démarre. # # Indispensable pour que Yavsc.Org (IdentityServer8) démarre.
# # Sans ce montage, 'docker compose up' échoue côté web avec # # Sans ce montage, 'docker compose up' échoue côté web avec
@ -71,7 +73,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: api-runtime target: api-runtime
args: args:
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 BUILD_ENV_TAG: debian12-dotnet10-android36-v1
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -88,7 +90,9 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
network_mode: host networks:
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# - /etc/letsencrypt:/etc/letsencrypt:ro # - /etc/letsencrypt:/etc/letsencrypt:ro
@ -101,7 +105,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: blogs-runtime target: blogs-runtime
args: args:
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 BUILD_ENV_TAG: debian12-dotnet10-android36-v1
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -118,7 +122,9 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
network_mode: host networks:
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# - /etc/letsencrypt:/etc/letsencrypt:ro # - /etc/letsencrypt:/etc/letsencrypt:ro

View file

@ -4,7 +4,7 @@
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management --> <!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.3</AvaloniaVersionBase> <AvaloniaVersionBase>12.1.2</AvaloniaVersionBase>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
@ -13,21 +13,16 @@
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.3" /> <PackageVersion Include="Avalonia.Headless" Version="12.1.2" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" 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" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" /> <PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" /> <PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
<PackageVersion Include="Material.Avalonia" Version="3.21.0" /> <PackageVersion Include="Material.Avalonia" Version="3.20.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <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="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.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
@ -36,4 +31,4 @@
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" /> <PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -2,7 +2,6 @@
using Android.App; using Android.App;
using Android.Content; using Android.Content;
using Android.Content.PM; using Android.Content.PM;
using Android.Views;
using AndroidX.Core.Provider; using AndroidX.Core.Provider;
using AndroidX.Emoji2.Text; using AndroidX.Emoji2.Text;
using Avalonia.Android; using Avalonia.Android;
@ -16,9 +15,7 @@ namespace PostIt.Android;
Theme = "@style/MyTheme.NoActionBar", Theme = "@style/MyTheme.NoActionBar",
Icon = "@drawable/icon", Icon = "@drawable/icon",
MainLauncher = true, MainLauncher = true,
WindowSoftInputMode = SoftInput.AdjustResize, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)
]
public class MainActivity : AvaloniaMainActivity public class MainActivity : AvaloniaMainActivity
{ {
/// <summary> /// <summary>

View file

@ -27,14 +27,4 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code </Project>
extension's injected HotReload agent (references Microsoft.Maui.*).
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
this file's item groups, so the removal must run in a target. -->
<Target Name="ExcludeMauiDeployHotReloadAgent" BeforeTargets="CoreCompile"
Condition="'$(MauiDeployHotReloadAgentSource)' != ''">
<ItemGroup>
<Compile Remove="$(MauiDeployHotReloadAgentSource)" />
</ItemGroup>
</Target>
</Project>

View file

@ -1,10 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application android:label="PostIt" android:icon="@drawable/Icon">
android:label="PostIt"
android:icon="@drawable/Icon"
android:networkSecurityConfig="@xml/network_security_config"
android:hardwareAccelerated="true">
</application> </application>
</manifest> </manifest>

View file

@ -1,6 +0,0 @@
<?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>

View file

@ -1,7 +1,6 @@
using System; using System;
using Avalonia; using Avalonia;
using PostIt.Services; using PostIt.Services;
using PostIt.CLIOptions;
namespace PostIt.Desktop; namespace PostIt.Desktop;

View file

@ -73,14 +73,6 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default) public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{ {
_recorder.Calls.Add((method, path, contentFactory)); _recorder.Calls.Add((method, path, contentFactory));
if (typeof(T) == typeof(BlogPostDto))
return Task.FromResult((T)(object)new BlogPostDto
{
Id = 42,
Title = "Mon premier billet",
AuthorId = "tester",
Article = "Contenu du billet de test.",
});
return Task.FromResult(default(T)!); return Task.FromResult(default(T)!);
} }
} }

View file

@ -20,13 +20,8 @@ namespace PostIt.Tests;
/// </summary> /// </summary>
public class BlogAttachmentLinkTests public class BlogAttachmentLinkTests
{ {
[Theory] [Fact]
[InlineData("note.txt", "note.txt")] public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority()
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
[InlineData("rapport final.pdf", "rapport%20final.pdf")]
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority(
string originalName, string urlName)
{ {
const string authority = "https://idp.example.test"; const string authority = "https://idp.example.test";
@ -58,59 +53,27 @@ public class BlogAttachmentLinkTests
vm.DraftTitle = "Après"; vm.DraftTitle = "Après";
vm.DraftArticle = "Contenu modifié."; vm.DraftArticle = "Contenu modifié.";
vm.DraftAttachments.Add(new BlogUploadFile( vm.DraftAttachments.Add(new BlogUploadFile(
originalName, "note.txt",
System.Text.Encoding.UTF8.GetBytes("payload test"), System.Text.Encoding.UTF8.GetBytes("payload test"),
"text/plain")); "text/plain"));
var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}";
Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle);
Assert.Empty(recorder.Calls);
var draftBeforeSave = vm.DraftArticle;
await vm.SaveAsync(); await vm.SaveAsync();
var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); // The update branch issues two PUTs: the multipart one
// carrying the file, then — because TryAppendAttachmentLinks
// appended a link — a JSON one carrying the final article.
var linkPut = recorder.Calls.LastOrDefault(
c => c.method == HttpMethod.Put);
Assert.NotEqual(default, linkPut); Assert.NotEqual(default, linkPut);
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body); var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent()); var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
var sentStringContent = Assert.IsType<StringContent>(multipartContent.First()); var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
var sentStringContent = multipartContent.First() as StringContent ;
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
using var document = System.Text.Json.JsonDocument.Parse(sentString); Assert.Contains($"- [note.txt]({expectedUrl})", sentString);
var article = document.RootElement.GetProperty("article").GetString();
Assert.Contains($"- [{originalName}]({expectedUrl})", article);
// The regression shape: a relative, authority-less link. // The regression shape: a relative, authority-less link.
Assert.DoesNotContain("](/files/", article); Assert.DoesNotContain("](/files/", sentString);
Assert.Equal(draftBeforeSave, vm.DraftArticle);
Assert.NotNull(vm.SelectedPost);
Assert.False(vm.IsDraftModified);
}
[Fact]
public async Task New_post_resolves_only_the_provisional_link_at_first_save()
{
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"),
new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } });
vm.DraftTitle = "Nouveau billet";
vm.DraftArticle = "Texte conservé.";
vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 }));
Assert.Contains("postit-attachment:", vm.DraftArticle);
Assert.Empty(recorder.Calls);
await vm.SaveAsync();
Assert.Equal("Nouveau billet", vm.DraftTitle);
Assert.Contains("Texte conservé.", vm.DraftArticle);
Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)",
vm.DraftArticle);
Assert.DoesNotContain("postit-attachment:", vm.DraftArticle);
Assert.Equal(42, vm.SelectedPost?.Id);
Assert.Empty(vm.DraftAttachments);
Assert.False(vm.IsDraftModified);
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post);
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
} }
} }

View file

@ -1,5 +1,4 @@
using System.Net.Http; using System.Net.Http;
using PostIt.Models;
using PostIt.ViewModels; using PostIt.ViewModels;
using Yavsc; using Yavsc;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -1,199 +0,0 @@
using System.Net.Http;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class HomePageNotificationsTests
{
[Fact]
public async Task Refresh_loads_public_notifications_from_the_business_host()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
transport.Response = Task.FromResult(new List<Notification> { Note(1, "Information") });
await vm.RefreshNotificationsAsync();
Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath);
Assert.True(transport.WasAnonymous);
Assert.Equal("Information", Assert.Single(vm.Notifications).title);
Assert.False(vm.IsLoadingNotifications);
Assert.False(vm.HasNoNotifications);
Assert.Null(vm.NotificationsError);
}
[Fact]
public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
vm.Notifications.Add(Note(1, "Old"));
await vm.RefreshNotificationsAsync();
Assert.Empty(vm.Notifications);
Assert.True(vm.HasNoNotifications);
transport.Response = Task.FromException<List<Notification>>(new HttpRequestException("Server unavailable"));
await vm.RefreshNotificationsAsync();
Assert.Empty(vm.Notifications);
Assert.False(vm.HasNoNotifications);
Assert.Contains("Server unavailable", vm.NotificationsError);
Assert.False(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Recovered") });
await vm.RefreshNotificationsAsync();
Assert.Null(vm.NotificationsError);
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
[Fact]
public async Task Refresh_ignores_an_older_response_after_a_newer_refresh()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
var oldResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = oldResponse.Task;
var oldLoad = vm.RefreshNotificationsAsync();
Assert.True(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Current") });
await vm.RefreshNotificationsAsync();
oldResponse.SetResult(new List<Notification> { Note(1, "Stale") });
await oldLoad;
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.False(vm.IsLoadingNotifications);
}
[AvaloniaFact]
public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data()
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json");
var settings = new Settings();
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var api = new YavscApiClient(settings, store);
try
{
var session = new SessionStatusViewModel { Api = api };
var transport = new NotificationTransport();
var vm = CreateViewModel(transport, session);
session.Refresh();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
var privateResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = privateResponse.Task;
var privateLoad = vm.RefreshNotificationsAsync();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
vm.Notifications.Add(Note(9, "Private"));
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Public") });
session.SetError("Disconnected");
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.True(transport.WasAnonymous);
privateResponse.SetResult(new List<Notification> { Note(1, "Late private data") });
await privateLoad;
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
File.Delete(tokenPath);
}
}
[AvaloniaFact]
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
{
var transport = new NotificationTransport
{
Response = Task.FromResult(new List<Notification> { Note(1, "Notification title") })
};
var vm = CreateViewModel(transport);
var page = new HomePage { DataContext = vm };
var nav = new NavigationPage();
var window = new Window { Content = nav, Width = 320, Height = 450 };
try
{
window.Show();
await nav.PushAsync(page);
window.UpdateLayout();
Assert.Single(vm.Notifications);
var texts = page.GetVisualDescendants().OfType<TextBlock>().ToArray();
Assert.Contains(texts, t => t.Text == "Notification title"
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
Assert.Contains(texts, t => t.Text == "Notification body"
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
b => b.Command == vm.RefreshNotificationsCommand);
Assert.True(refresh.IsEnabled);
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
s => s.Extent.Height > s.Viewport.Height);
await nav.PushAsync(new ContentPage());
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
await nav.PopAsync();
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
window.Close();
}
}
private static HomePageViewModel CreateViewModel(NotificationTransport transport,
SessionStatusViewModel? session = null)
=> new(null!, new Settings(), session ?? new SessionStatusViewModel(),
new NotificationsApiClient(transport, "https://business.example/api/v1/"));
private static Notification Note(long id, string title)
=> new() { Id = id, title = title, body = "Notification body", click_action = "Close" };
private sealed class NotificationTransport : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public bool WasAnonymous { get; private set; }
public Task<List<Notification>> Response { get; set; } = Task.FromResult(new List<Notification>());
public Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> ReadAsync<T>(path, true);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
Assert.Equal(HttpMethod.Get, method);
return ReadAsync<T>(path, false);
}
private async Task<T> ReadAsync<T>(string path, bool anonymous)
{
LastPath = path;
WasAnonymous = anonymous;
return (T)(object)await Response;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public ValueTask DisposeAsync()
{
Http.Dispose();
return ValueTask.CompletedTask;
}
}
}

View file

@ -1,4 +1,3 @@
using Yavsc.Abstract.Identity;
using PostIt.ViewModels; using PostIt.ViewModels;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -13,170 +12,4 @@ public class HomePageProviderFlowTests
Assert.NotNull(vm.OpenProviderRequests); Assert.NotNull(vm.OpenProviderRequests);
Assert.True(vm.OpenProviderRequests.CanExecute(null)); Assert.True(vm.OpenProviderRequests.CanExecute(null));
} }
[Fact]
public void HomePage_exposes_profile_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenProfile);
Assert.True(vm.OpenProfile.CanExecute(null));
}
[Fact]
public void HomePage_exposes_performer_configuration_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenPerformerConfiguration);
Assert.True(vm.OpenPerformerConfiguration.CanExecute(null));
}
[Fact]
public void HomePage_exposes_administration_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenAdministration);
Assert.True(vm.OpenAdministration.CanExecute(null));
}
[Fact]
public void Administration_page_filters_users_by_search_text()
{
var vm = new AdministrationPageViewModel();
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "3", UserName = "carol", FullName = "Carol Smith" });
vm.SearchText = "ali";
Assert.Single(vm.FilteredUsers);
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
}
[Fact]
public void Administration_page_filters_users_by_full_name()
{
var vm = new AdministrationPageViewModel();
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" });
vm.SearchText = "example";
Assert.Single(vm.FilteredUsers);
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
}
[Fact]
public void Administration_page_tracks_selected_user()
{
var vm = new AdministrationPageViewModel();
var user = new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example", Email = "alice@example.com", DedicatedGoogleCalendar = "calendar-123" };
vm.Users.Add(user);
vm.SelectedUser = user;
Assert.Same(user, vm.SelectedUser);
Assert.Equal("alice", vm.SelectedUser?.UserName);
}
[Fact]
public void UserProfilePage_exposes_full_name_and_address_fields()
{
var vm = new UserProfilePageViewModel();
Assert.Equal("—", vm.FullName);
Assert.Equal("—", vm.Address);
Assert.Equal("Non configuré", vm.DedicatedGoogleCalendar);
Assert.Equal("Aucune information bancaire", vm.BankInfoSummary);
Assert.NotNull(vm.RefreshCommand);
}
[Fact]
public void ProfileUpdateRequest_allows_username_updates()
{
var request = new ProfileUpdateRequest
{
UserName = "alice.new"
};
Assert.Equal("alice.new", request.UserName);
}
[Fact]
public void UserProfilePage_can_save_profile_values()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "calendar-123",
BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606"
};
Assert.NotNull(vm.SaveProfileCommand);
Assert.False(vm.SaveProfileCommand.CanExecute(null));
}
[Fact]
public void UserProfilePage_builds_profile_payload_for_calendar_and_bank_values()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "calendar-123",
BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606"
};
var payload = vm.BuildProfileUpdateRequest();
Assert.Equal("alice.new", payload.UserName);
Assert.Equal("calendar-123", payload.GoogleCalendarId);
Assert.Null(payload.BankInfoSummary);
}
[Fact]
public void UserProfilePage_turns_placeholder_values_into_empty_payload_fields()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "Non configuré",
BankInfoSummary = "Aucune information bancaire"
};
var payload = vm.BuildProfileUpdateRequest();
Assert.Equal(string.Empty, payload.GoogleCalendarId);
Assert.Null(payload.BankInfoSummary);
}
[Fact]
public void PerformerConfigurationPage_preserves_current_user_id_in_save_payload()
{
var vm = new PerformerConfigurationPageViewModel
{
PerformerId = "user-42",
UserName = "alice",
ExerciseCountryCode = "fr",
SIREN = "123456789",
Website = "https://example.com",
Active = true,
AcceptNotifications = true,
AcceptPublicContact = true,
UseGeoLocalizationToReduceDistanceWithClients = true
};
var payload = vm.BuildPerformerProfileSettings();
Assert.Equal("user-42", payload.PerformerId);
Assert.Equal("alice", payload.UserName);
Assert.Equal("fr", payload.ExerciseCountryCode);
}
} }

View file

@ -1,34 +0,0 @@
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)

View file

@ -1,143 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless.XUnit;
using Avalonia.LogicalTree;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using PostIt.Controls;
using PostIt.Views.Blogs;
namespace PostIt.Tests;
public class MarkdownEditorScrollTests
{
private static readonly string LongArticle =
string.Join("\n\n", Enumerable.Range(1, 100).Select(i => $"Paragraph {i}: article content."));
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Long_article_scrolls_to_the_end_and_adapts_to_resizing(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = LongArticle,
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
AssertScrollable(scroller);
var initialViewport = scroller.Viewport.Height;
window.Height = 300;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Assert.True(scroller.Viewport.Height < initialViewport);
AssertScrollable(scroller);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Blog_page_bounds_the_article_viewport(bool preview)
{
var page = new BlogsPage();
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
editor.MarkdownText = LongArticle;
editor.IsPreviewVisible = preview;
var posts = page.FindControl<ListBox>("PostsListBox")!;
posts.ItemsSource = Enumerable.Range(1, 50)
.Select(i => new Yavsc.Blogspot.BlogPostDto { Title = $"Post {i}" }).ToArray();
var navigation = new NavigationPage();
navigation.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Width = 800, Height = 700, Content = navigation };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.InRange(scroller.Viewport.Height, 1, window.ClientSize.Height);
AssertScrollable(scroller);
AssertScrollable(posts.GetVisualDescendants().OfType<ScrollViewer>().Single());
var editorBottom = editor.TranslatePoint(new Point(0, editor.Bounds.Height), window);
Assert.NotNull(editorBottom);
Assert.InRange(editorBottom.Value.Y, 1, window.ClientSize.Height);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Short_article_needs_no_vertical_scrollbar(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = "Short article",
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.True(scroller.Extent.Height <= scroller.Viewport.Height);
Assert.DoesNotContain(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
}
finally
{
CloseWindow(window, editor);
}
}
private static void CloseWindow(Window window, MarkdownEditorControl editor)
{
// The package's shared renderer caches the visual; detach it between headless applications.
editor.FindControl<MarkdownViewer.Core.Controls.MarkdownViewer>("PreviewViewer")!.Content = null;
window.UpdateLayout();
window.Close();
}
private static ScrollViewer FindArticleScroller(MarkdownEditorControl editor, bool preview)
{
if (!preview)
return editor.GetVisualDescendants().OfType<TextEditor>().Single()
.GetVisualDescendants().OfType<ScrollViewer>().Single();
return editor.FindControl<ScrollViewer>("PreviewScrollViewer")!;
}
private static void AssertScrollable(ScrollViewer scroller)
{
Assert.True(scroller.Extent.Height > scroller.Viewport.Height,
$"Expected overflow, but extent={scroller.Extent.Height}, viewport={scroller.Viewport.Height}.");
Assert.Contains(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
scroller.ScrollToEnd();
Assert.True(scroller.Offset.Y > 0);
Assert.Equal(scroller.Extent.Height - scroller.Viewport.Height, scroller.Offset.Y, 1);
}
}

View file

@ -1,208 +0,0 @@
using System.Net.Http;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using CommunityToolkit.Mvvm.Input;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Headless UI coverage for the "Mes fichiers" page
/// (<see cref="MyFilesPage"/>). The user reported that once a file
/// is listed, "l'item est inerte au click, et ses boutons restent
/// grisés, désactivés" — neither the row nor its action buttons
/// (Télécharger / Supprimer) respond to a click.
///
/// <para>These tests mount a real <see cref="MyFilesPage"/> under
/// the Avalonia headless platform with a stub
/// <see cref="IYavscApiClient"/> that lists one file, then walk the
/// realised visual tree to find the row's action buttons and
/// assert the two things that would silently break and reproduce
/// the "grayed / inert" symptom:</para>
/// <list type="number">
/// <item><description>The <c>Command</c> binding
/// (<c>$parent[vm:MyFilesViewModel].DownloadFileCommand</c>)
/// resolved to a non-null command — a null command leaves the
/// button disabled (grayed) and click-silent.</description></item>
/// <item><description>The command is <b>armed</b>
/// (<c>CanExecute</c> true) for the listed file — an
/// <c>AsyncRelayCommand</c> with
/// <c>AllowConcurrentExecutions=false</c> (the
/// <see cref="CommunityToolkit.Mvvm.Input.RelayCommandAttribute"/>
/// default) disables itself while running; a button stuck
/// "running" is exactly the grayed-and-inert report.</description></item>
/// <item><description>Executing the command actually invokes the
/// file download / delete on the API client — the row
/// "activates" on click instead of being inert.</description></item>
/// </list>
/// <para>The OS save picker is absent under headless, so
/// <c>FileSaveHelpers.SaveAsync</c> throws or cancels and the
/// download command's <c>catch</c> sets an error status — but the
/// API download is awaited <em>before</em> the picker, so the call
/// is recorded regardless. We do not <c>await</c> the command's
/// <c>ExecutionTask</c> to completion, to avoid blocking on the
/// (platform-dependent) picker.</para>
/// </summary>
public class MyFilesPageHeadlessTests
{
private static (MyFilesPage page, MyFilesViewModel vm, StubFsApi api) CreatePage()
{
var api = new StubFsApi();
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
var vm = new MyFilesViewModel(fsClient);
var page = new MyFilesPage { DataContext = vm };
return (page, vm, api);
}
/// <summary>
/// Mount the page the way the app does — pushed onto a
/// <see cref="NavigationPage"/> shown in a <see cref="Window"/>
/// (a <see cref="ContentPage"/> realizes its content template via
/// the navigation host, cf. <c>RdvPageHeadlessTests</c>) — after
/// listing a single file by awaiting
/// <see cref="MyFilesViewModel.InitializeAsync"/>. Populating
/// <see cref="MyFilesViewModel.Files"/> before the first layout
/// pass lets the <c>ItemsControl</c> realise its item templates
/// under headless.
/// </summary>
private static async Task<(MyFilesPage page, MyFilesViewModel vm, StubFsApi api)> MountAsync()
{
var (page, vm, api) = CreatePage();
await vm.InitializeAsync();
var nav = new NavigationPage();
nav.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Content = nav };
window.Show();
// Let bindings / the ItemsControl item templates apply.
await Task.Delay(50);
return (page, vm, api);
}
/// <summary>
/// Find the first action button in a file row whose content
/// matches <paramref name="content"/>. The file rows live in an
/// <c>ItemsControl</c> DataTemplate (no <c>x:Name</c>), so we
/// walk the visual tree.
/// </summary>
private static Button? FindRowButton(MyFilesPage page, string content)
=> page.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => b.Content is string s && s == content);
[AvaloniaFact]
public async Task Download_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
// Sanity: the listing populated a file row.
Assert.NotEmpty(vm.Files);
Assert.NotNull(vm.Files[0].Id);
var downloadBtn = FindRowButton(page, "Télécharger");
Assert.NotNull(downloadBtn);
Assert.NotNull(downloadBtn!.Command); // binding resolved (not grayed-via-null)
var param = downloadBtn.CommandParameter;
Assert.True(downloadBtn.Command!.CanExecute(param), // armed (not stuck running)
"Télécharger must be armed (CanExecute true) for a listed file.");
// Act: click → fire the command.
downloadBtn.Command.Execute(param);
// Assert: the row activated — the API download was invoked
// (it is awaited before the headless-absent save picker).
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Get && c.Path == "fs/file/42"))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Télécharger must invoke GET fs/file/{id} on the API client.");
}
[AvaloniaFact]
public async Task Delete_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
Assert.NotEmpty(vm.Files);
var deleteBtn = FindRowButton(page, "Supprimer");
Assert.NotNull(deleteBtn);
Assert.NotNull(deleteBtn!.Command);
var param = deleteBtn.CommandParameter;
Assert.True(deleteBtn.Command!.CanExecute(param),
"Supprimer must be armed (CanExecute true) for a listed file.");
deleteBtn.Command.Execute(param);
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Delete))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Supprimer must invoke DELETE fs/{path} on the API client.");
}
private sealed class StubFsApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
=> Calls.Add((method, path, body));
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (typeof(T) == typeof(UserDirectoryDto))
{
var dir = new UserDirectoryDto
{
UserName = "alice",
Files = new[]
{
new FileEntryDto { Id = 42, Name = "note.txt", Size = 4 }
}
};
return Task.FromResult((T)(object)dir);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(new byte[] { 1, 2, 3, 4 });
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -21,11 +21,9 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
private readonly RSA _rsa; private readonly RSA _rsa;
private readonly string _kid; private readonly string _kid;
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private readonly HashSet<string> _refreshTokens = new(StringComparer.Ordinal);
public string Issuer { get; } public string Issuer { get; }
public string LoopbackRedirectUri { get; } public string LoopbackRedirectUri { get; }
public int RefreshRequestCount { get; private set; }
private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback) private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
{ {
@ -102,7 +100,7 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
["response_types_supported"] = new[] { "code" }, ["response_types_supported"] = new[] { "code" },
["subject_types_supported"] = new[] { "public" }, ["subject_types_supported"] = new[] { "public" },
["id_token_signing_alg_values_supported"] = new[] { "RS256" }, ["id_token_signing_alg_values_supported"] = new[] { "RS256" },
["grant_types_supported"] = new[] { "authorization_code", "refresh_token" }, ["grant_types_supported"] = new[] { "authorization_code" },
["code_challenge_methods_supported"] = new[] { "S256" }, ["code_challenge_methods_supported"] = new[] { "S256" },
}; };
@ -134,16 +132,6 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
body = await reader.ReadToEndAsync(); body = await reader.ReadToEndAsync();
var form = ParseForm(body); var form = ParseForm(body);
if (form.TryGetValue("grant_type", out var grantType) && grantType == "refresh_token")
{
RefreshRequestCount++;
if (!form.TryGetValue("refresh_token", out var suppliedToken)
|| !_refreshTokens.Remove(suppliedToken))
{
await WriteJsonAsync(ctx.Response, new { error = "invalid_grant" }, 400);
return;
}
}
// We accept any code and don't validate PKCE on the stub side; // We accept any code and don't validate PKCE on the stub side;
// the OidcClient itself validates the redirect_uri match. // the OidcClient itself validates the redirect_uri match.
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@ -154,13 +142,11 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests", ["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests",
["exp"] = now + 600, ["exp"] = now + 600,
["iat"] = now, ["iat"] = now,
["jti"] = Guid.NewGuid().ToString("N"),
}; };
var accessToken = SignJwt(claims); var accessToken = SignJwt(claims);
var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_'); .TrimEnd('=').Replace('+', '-').Replace('/', '_');
_refreshTokens.Add(refreshToken);
var response = new var response = new
{ {
access_token = accessToken, access_token = accessToken,
@ -207,10 +193,10 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
return dict; return dict;
} }
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload, int statusCode = 200) private static async Task WriteJsonAsync(HttpListenerResponse response, object payload)
{ {
response.ContentType = "application/json"; response.ContentType = "application/json";
response.StatusCode = statusCode; response.StatusCode = 200;
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload)); var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
await response.OutputStream.WriteAsync(bytes); await response.OutputStream.WriteAsync(bytes);
response.Close(); response.Close();

View file

@ -1,132 +1,12 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views.Blogs;
namespace PostIt.Tests; namespace PostIt.Tests;
public class PostItViewModelTests public class PostItViewModelTests
{ {
[Fact]
public void Draft_modified_tracks_title_body_and_attachments_not_publication()
{
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
vm.SelectedPost = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
Assert.False(vm.IsDraftModified);
vm.DraftIsPublished = true;
Assert.False(vm.IsDraftModified);
vm.DraftTitle = "Autre";
Assert.True(vm.IsDraftModified);
vm.DraftTitle = "Titre";
Assert.False(vm.IsDraftModified);
vm.DraftArticleDocument!.Insert(0, "Edit ");
Assert.True(vm.IsDraftModified);
vm.DraftArticle = "Corps";
Assert.False(vm.IsDraftModified);
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
Assert.True(vm.IsDraftModified);
}
[AvaloniaFact]
public async Task Save_keeps_selection_and_editor_open_and_resets_highlight()
{
var recorder = new CallRecorder();
var vm = new BlogsViewModel(new BlogApiClient(new RecordingYavscApiClient(recorder),
"https://blogs.example.test/api/v1/"));
var selected = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
vm.Posts.Add(selected);
vm.SearchText = "";
vm.SelectedPost = selected;
var page = new BlogsPage { DataContext = vm };
var navigation = new NavigationPage();
await navigation.PushAsync(page);
var window = new Window { Content = navigation };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
selected = Assert.Single(vm.FilteredPosts);
selected.AuthorId = "tester";
selected.Title = "Titre";
selected.Article = "Corps";
page.PostsListBox.SelectedItem = selected;
Dispatcher.UIThread.RunJobs();
Assert.Same(selected, page.PostsListBox.SelectedItem);
Assert.Same(selected, vm.SelectedPost);
Assert.DoesNotContain("modified", page.SaveButton.Classes);
vm.DraftTitle = "Titre modifié";
Dispatcher.UIThread.RunJobs();
Assert.Contains("modified", page.SaveButton.Classes);
Assert.Equal(Avalonia.Media.FontWeight.Bold, page.SaveButton.FontWeight);
Assert.Same(vm.SaveCommand, page.SaveButton.Command);
await vm.SaveAsync();
Dispatcher.UIThread.RunJobs();
Assert.Same(page, navigation.NavigationStack.Last());
Assert.Same(selected, vm.SelectedPost);
Assert.Same(selected, page.PostsListBox.SelectedItem);
Assert.Equal("Titre modifié", vm.DraftTitle);
Assert.Equal("Corps", vm.DraftArticle);
Assert.DoesNotContain("modified", page.SaveButton.Classes);
}
finally
{
window.Close();
}
}
[Fact]
public async Task Failed_save_preserves_draft_attachments_and_modified_state()
{
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
var post = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
vm.SelectedPost = post;
vm.DraftTitle = "Titre modifié";
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
var article = vm.DraftArticle;
await vm.SaveAsync();
Assert.Same(post, vm.SelectedPost);
Assert.Equal("Titre modifié", vm.DraftTitle);
Assert.Equal(article, vm.DraftArticle);
Assert.Single(vm.DraftAttachments);
Assert.True(vm.IsDraftModified);
Assert.False(vm.IsBusy);
}
[AvaloniaFact]
public void Published_badge_tracks_post_publication_state()
{
var post = new BlogPostDto { Id = 42, Title = "Test post", IsPublished = true };
var page = new BlogsPage();
var row = page.PostsListBox.ItemTemplate!.Build(post)!;
row.DataContext = post;
var window = new Window { Content = row };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var badge = Assert.Single(row.GetVisualDescendants().OfType<Border>(),
border => border.Name == "PublishedBadge");
Assert.True(badge.IsVisible);
post.IsPublished = false;
Dispatcher.UIThread.RunJobs();
Assert.False(badge.IsVisible);
post.IsPublished = true;
Dispatcher.UIThread.RunJobs();
Assert.True(badge.IsVisible);
}
finally
{
window.Close();
}
}
[Fact] [Fact]
public void SearchCommand_filters_posts_by_title_article_or_author() public void SearchCommand_filters_posts_by_title_article_or_author()
@ -181,22 +61,11 @@ public class PostItViewModelTests
var viewModel = new BlogsViewModel(blog); var viewModel = new BlogsViewModel(blog);
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false }; viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
var changes = new List<string?>();
viewModel.SelectedPost.PropertyChanged += (_, args) => changes.Add(args.PropertyName);
await viewModel.SetPublishStateAsync(true); await viewModel.SetPublishStateAsync(true);
Assert.True(api.LastPublishValue); Assert.True(api.LastPublishValue);
Assert.True(viewModel.DraftIsPublished); Assert.True(viewModel.DraftIsPublished);
Assert.True(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished) }, changes);
await viewModel.SetPublishStateAsync(false);
Assert.False(api.LastPublishValue);
Assert.False(viewModel.DraftIsPublished);
Assert.False(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished), nameof(BlogPostDto.IsPublished) }, changes);
} }
/// <summary>Test fake that always throws if the API is invoked.</summary> /// <summary>Test fake that always throws if the API is invoked.</summary>

View file

@ -1,117 +0,0 @@
using System.Net.Http;
using System.Text.Json;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ResourceUsageApiClientTests
{
[Fact]
public async Task GetCurrentAsync_calls_user_usage_endpoint()
{
var fake = new FakeYavscApiClient();
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
var usage = await client.GetCurrentAsync();
Assert.Equal("https://example.invalid/api/v1/resource-usage/me", fake.LastPath);
Assert.Equal("GET", fake.LastMethod);
Assert.Equal(12.5m, usage.ApiCalls);
Assert.Equal(3.2m, usage.CpuSeconds);
}
[Fact]
public async Task GetAdminOverviewAsync_calls_admin_usage_endpoint()
{
var fake = new FakeYavscApiClient();
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
var usage = await client.GetAdminOverviewAsync();
Assert.Equal("https://example.invalid/api/v1/resource-usage/admin", fake.LastPath);
Assert.Equal("GET", fake.LastMethod);
Assert.Equal(2, usage.Count);
Assert.Equal("administrator", usage[0].UserName);
}
private sealed class FakeYavscApiClient : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public string? LastMethod { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method.Method;
LastPath = path;
if (typeof(T) == typeof(ResourceUsageSummary))
{
var summary = new ResourceUsageSummary
{
UserId = "user-42",
UserName = "alice",
ApiCalls = 12.5m,
CpuSeconds = 3.2m,
BandwidthMb = 18.4m,
StorageMb = 51.2m,
EstimatedCompensation = 4.9m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
};
return Task.FromResult((T)(object)summary);
}
if (typeof(T) == typeof(List<ResourceUsageSummary>))
{
var list = new List<ResourceUsageSummary>
{
new()
{
UserId = "admin-user",
UserName = "administrator",
ApiCalls = 90m,
CpuSeconds = 14m,
BandwidthMb = 500m,
StorageMb = 2048m,
EstimatedCompensation = 32m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
},
new()
{
UserId = "user-99",
UserName = "bob",
ApiCalls = 40m,
CpuSeconds = 7m,
BandwidthMb = 120m,
StorageMb = 600m,
EstimatedCompensation = 15m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
}
};
return Task.FromResult((T)(object)list);
}
throw new NotSupportedException($"Unsupported type: {typeof(T).FullName}");
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> Task.CompletedTask;
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
=> Task.FromResult(Array.Empty<byte>());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -1,5 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.Styling;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
@ -22,36 +23,41 @@ namespace PostIt.Tests;
/// <c>new Window().Show()</c> throws because the harness has not /// <c>new Window().Show()</c> throws because the harness has not
/// been initialised for that thread.</para> /// been initialised for that thread.</para>
/// ///
/// <para>These tests set the DataContext directly on the banner /// <para>The session banner's <c>DataContext</c> is not wired in
/// mounted in the window. The production code path is exercised /// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
/// not called in a unit test, so we set the DataContext on the
/// banner directly. The production code path is exercised
/// end-to-end by the manual launch, not here.</para> /// end-to-end by the manual launch, not here.</para>
/// </summary> /// </summary>
public class SessionStatusBannerTests : IDisposable public class SessionStatusBannerTests
{ {
private MainWindow? _window;
[AvaloniaFact] [AvaloniaFact]
public void Banner_renders_four_buttons_in_the_visual_tree() public void Banner_renders_three_buttons_in_the_visual_tree()
{ {
SessionStatusBanner banner = CreateBanner(); MainWindow window = new MainWindow();
window.Show();
var buttons = banner.GetVisualDescendants() var buttons = window.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.ToList(); .ToList();
// Three buttons, named by their content text: Se
// déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the
// corresponding navigation event.
Assert.Equal(4, buttons.Count); Assert.Equal(4, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter"); Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter"); Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres"); Assert.Contains(buttons, b => b.Content as string == "Paramètres");
Assert.Contains(buttons, b => b.Content as string == "À propos");
} }
[AvaloniaFact] [AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out() public void Banner_login_button_is_visible_when_logged_out()
{ {
SessionStatusBanner banner = CreateBanner(); MainWindow window = new MainWindow();
window.Show();
var login = banner.GetVisualDescendants() var login = window.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Se connecter"); .Single(b => b.Content as string == "Se connecter");
@ -63,9 +69,12 @@ public class SessionStatusBannerTests : IDisposable
[AvaloniaFact] [AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out() public void Banner_logout_button_is_hidden_when_logged_out()
{ {
SessionStatusBanner banner = CreateBanner(); SessionStatusBanner banner = CreateBanner();
Assert.False((banner.DataContext as SessionStatusViewModel)!
.IsLoggedIn); // VM default
Assert.False(Assert.IsType<SessionStatusViewModel>(banner.DataContext).IsLoggedIn);
var logout = banner.GetVisualDescendants() var logout = banner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter"); .Single(b => b.Content as string == "Se déconnecter");
@ -82,23 +91,22 @@ public class SessionStatusBannerTests : IDisposable
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Paramètres"); .Single(b => b.Content as string == "Paramètres");
// Paramètres has no IsVisible binding and is always shown. // Paramètres is the only button with no IsVisible
// binding — always shown. The user's only path to the
// settings page goes through this button.
Assert.True(settings.IsVisible); Assert.True(settings.IsVisible);
} }
private SessionStatusBanner CreateBanner() private static SessionStatusBanner CreateBanner()
{ {
_window = new MainWindow(); MainWindow window = new MainWindow();
_window.Show(); window.Show();
var banner = _window.GetVisualDescendants() var banner = window.MainView.SessionBanner;
.OfType<SessionStatusBanner>() var status = new SessionStatusViewModel();
.Single(); banner.DataContext = status;
banner.DataContext = new SessionStatusViewModel();
return banner; return banner;
} }
public void Dispose() => _window?.Close();
[AvaloniaFact] [AvaloniaFact]
public void Banner_session_label_reflects_DataContext() public void Banner_session_label_reflects_DataContext()
{ {

View file

@ -1,38 +0,0 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Controls;
using PostIt.Models;
using PostIt.ViewModels;
namespace PostIt.Tests;
/// <summary>
/// The <see cref="StatusBar"/> surfaces the page's action status
/// (success / warning / error message). The user reported being
/// unable to copy these messages — the status text was a
/// <c>TextBlock</c>, which Avalonia does not make selectable. It is
/// now a <see cref="SelectableTextBlock"/> so the message can be
/// selected and copied (Ctrl+C / context menu). This test pins that:
/// if the message control regresses to a plain <c>TextBlock</c>,
/// selectability is lost and the test fails.
/// </summary>
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 window = new Window { Content = bar };
window.Show();
var message = bar.GetVisualDescendants()
.OfType<SelectableTextBlock>()
.FirstOrDefault();
Assert.NotNull(message);
Assert.Equal("Échec de la suppression : boom", message!.Text);
}
}

View file

@ -17,56 +17,6 @@ namespace PostIt.Tests;
/// </summary> /// </summary>
public class YavscApiClientTests public class YavscApiClientTests
{ {
[Fact]
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
{
using var apiServer = new StubApiServer(forceFirstRequest: true);
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
await using var client = new YavscApiClient(new Settings(), new TokenStore(tokensPath));
await Assert.ThrowsAsync<HttpRequestException>(() =>
client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken));
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GetAnonymousAsync_does_not_require_or_send_session_credentials(bool hasSession)
{
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
var store = new TokenStore(tokensPath);
if (hasSession)
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var client = new YavscApiClient(new Settings(), store);
var posts = await client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken);
Assert.Equal(2, posts.Count);
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
private static int GetFreePort() private static int GetFreePort()
{ {
var l = new TcpListener(IPAddress.Loopback, 0); var l = new TcpListener(IPAddress.Loopback, 0);
@ -420,105 +370,20 @@ public class YavscApiClientTests
} }
} }
[Fact] // SKIPPED — see comment.
public async Task Concurrent_silent_login_and_api_calls_share_one_refresh() //
{ // We can't cover "TrySilentLoginAsync purges the store when the
using var authority = await OIDCStubAuthority.StartAsync(); // refresh token is rejected" with OidcStubAuthority: the stub's
using var apiServer = new StubApiServer(); // /connect/token endpoint is unconditional and hands out a fresh
await apiServer.StartAsync(); // refresh token regardless of what the caller sends. To exercise
var settings = BuildSettings(authority, apiServer.BaseUrl); // the RefreshFailedException path we'd need an authority option
var tokensPath = TokensPath(); // to fail on a specific refresh-token string; until then the
try // production refresh-failure path is covered manually (and by
{ // the structural guarantee that _store.Clear() runs in the catch
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath); // block of ForceRefreshAsync when result.IsError).
ExpireCachedAccessToken(tokensPath); //
var store = new TokenStore(tokensPath); // [Fact]
var previousRefreshToken = store.Load()!.RefreshToken; // public async Task TrySilentLoginAsync_purges_store_when_refresh_fails_permanently() { ... }
await using var client = new YavscApiClient(settings, store);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
var ct = TestContext.Current.CancellationToken;
var silentLogins = Enumerable.Range(0, 8)
.Select(_ => client.TrySilentLoginAsync(null, ct)).ToArray();
var calls = Enumerable.Range(0, 8)
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
await Task.WhenAll(silentLogins.Cast<Task>().Concat(calls));
Assert.All(silentLogins, login => Assert.True(login.Result));
Assert.All(calls, call => Assert.NotEmpty(call.Result));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.NotEqual(previousRefreshToken, store.Load()!.RefreshToken);
Assert.True(client.HasValidSession);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
[Fact]
public async Task Concurrent_401_responses_share_one_refresh()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var client = await LoginAndPersistAsync(settings, authority, tokensPath);
apiServer.RejectedBearer = $"Bearer {client.CurrentAccessToken}";
var ct = TestContext.Current.CancellationToken;
var calls = Enumerable.Range(0, 8)
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
await Task.WhenAll(calls);
Assert.All(calls, call => Assert.NotEmpty(call.Result));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.True(client.HasValidSession);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
[Fact]
public async Task CallAsync_reports_expired_session_and_clears_rejected_refresh_token()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
var store = new TokenStore(tokensPath);
var tokens = store.Load()!;
store.Save(tokens with
{
RefreshToken = "revoked-refresh-token",
AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5)
});
await using var client = new YavscApiClient(settings, store);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
var error = await Assert.ThrowsAsync<RefreshFailedException>(() =>
client.CallAsync<JsonElement>(HttpMethod.Get, "posts", TestContext.Current.CancellationToken));
Assert.True(error.IsPermanent);
Assert.Contains("reconnecter", error.Message);
Assert.False(client.HasValidSession);
Assert.Null(store.Load());
Assert.Equal(0, apiServer.RequestCount);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
private static T Last<T>(System.Collections.Generic.List<T> list) private static T Last<T>(System.Collections.Generic.List<T> list)
{ {
@ -602,7 +467,6 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
public string BaseUrl { get; private set; } = string.Empty; public string BaseUrl { get; private set; } = string.Empty;
public List<string> SeenBearers { get; } = new(); public List<string> SeenBearers { get; } = new();
public int RequestCount => _requestCount; public int RequestCount => _requestCount;
public string? RejectedBearer { get; set; }
public StubApiServer(bool forceFirstRequest = false) public StubApiServer(bool forceFirstRequest = false)
{ {
@ -635,8 +499,7 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
if (!string.IsNullOrEmpty(auth)) if (!string.IsNullOrEmpty(auth))
SeenBearers.Add(auth!); SeenBearers.Add(auth!);
if ((_forceFirstRequest && _requestCount == 1) if (_forceFirstRequest && _requestCount == 1)
|| (RejectedBearer is not null && auth == RejectedBearer))
{ {
ctx.Response.StatusCode = 401; ctx.Response.StatusCode = 401;
ctx.Response.Close(); ctx.Response.Close();

View file

@ -6,26 +6,18 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using PostIt.Helpers; using PostIt.Helpers;
using System.Globalization;
using Live.Avalonia;
using System.Diagnostics;
using System.Linq;
using ReactiveUI;
using System.Reactive;
namespace PostIt; namespace PostIt;
public partial class App : Application, ILiveView, IVMPusherApp public partial class App : Application
{ {
public static bool LiveMode { get; private set; }
private int _bootStarted; private int _bootStarted;
public static string? CliConfigFileSpecification { get; private set; } public string? CliConfigFileSpecification {  get; private set; }
/// <summary> /// <summary>
/// DI container the platform entry points hand to ViewModels so /// DI container the platform entry points hand to ViewModels so
@ -50,122 +42,30 @@ public partial class App : Application, ILiveView, IVMPusherApp
{ {
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = new ServiceCollection().BuildPostItServices(); this.ServiceProvider = new ServiceCollection().BuildServices();
var settings = ServiceProvider.GetRequiredService<Settings>(); var settings = ServiceProvider.GetRequiredService<Settings>();
if (!LiveMode) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
// Debugging requires pdb loading etc, so we disable live reloading InitializeClassicDesktopLifetime(settings, desktop);
// during a test run with an attached debugger.
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
InitializeClassicDesktopLifetime(settings, desktop);
}
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
InitializeSingleViewLifetime(settings, singleViewPlatform);
}
} }
else else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{ {
// Here, we create a new LiveViewHost, located in the 'Live.Avalonia' InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
// namespace, and pass an ILiveView implementation to it. The ILiveView }
// implementation should have a parameterless constructor! Next, we else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
// start listening for any changes in the source files. And then, we {
// show the LiveViewHost window. Simple enough, huh? InitializeSingleViewLifetime(settings, singleViewPlatform);
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(); 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) public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform)
{ {
singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>(); singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>();
ConfigureRootView(View); ConfigureRootView(View);
ApplyStettings(settings); ApplyDarkMode(settings);
} }
public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime) public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
@ -174,7 +74,7 @@ public partial class App : Application, ILiveView, IVMPusherApp
() => () =>
{ {
this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>()); this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>());
ApplyStettings(settings); ApplyDarkMode(settings);
return View!; return View!;
}; };
} }
@ -182,35 +82,37 @@ public partial class App : Application, ILiveView, IVMPusherApp
public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop) public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
{ {
var window = ServiceProvider!.GetRequiredService<MainWindow>(); var window = ServiceProvider!.GetRequiredService<MainWindow>();
var mainView = (MainView)CreateView(window);
InitializeClassicDesktopLifetime(window, mainView, settings, desktop);
}
public void InitializeClassicDesktopLifetime(Window window, MainView mainView, Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
{
this.ConfigureRootView(mainView);
ApplyStettings(settings);
desktop.MainWindow = window; desktop.MainWindow = window;
window.Content = mainView; this.ConfigureRootView(window.MainView);
window.Show();
ApplyDarkMode(settings);
} }
public void ConfigureRootView(MainView rootView) 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();
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{ {
rootView.AttachedToVisualTree += (_, _) => // Remplacer Window.NavRoot par rootView.NavRoot
Dispatcher.UIThread.Post(() => _ = BootOnceAsync()); rootView.NavRoot.PopToRootAsync();
};
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>(); rootView.SessionBanner.DataContext = sessionStatus;
sessionStatus.LogoutCompleted += () => this.View = rootView;
}
private async Task BootOnceAsync()
{
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
{ {
// Remplacer Window.NavRoot par rootView.NavRoot return;
rootView.NavRoot.PopToRootAsync(); }
};
if (rootView.DataContext==null)
rootView.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
rootView.SessionBanner.DataContext = sessionStatus; var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
this.View = rootView; await BootAsync(this.ServiceProvider!, api);
} }
/// <summary> /// <summary>
@ -224,24 +126,10 @@ public partial class App : Application, ILiveView, IVMPusherApp
View = mainView ?? throw new ArgumentNullException(nameof(mainView)); View = mainView ?? throw new ArgumentNullException(nameof(mainView));
} }
private static void ApplyStettings(Settings settings) private static void ApplyDarkMode(Settings settings)
{ {
if (!string.IsNullOrWhiteSpace(settings.SelectedLanguage)) Application.Current!.RequestedThemeVariant =
Yavsc.Lang.Resources.Culture = new CultureInfo(settings.SelectedLanguage); settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
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> /// <summary>
@ -259,9 +147,9 @@ public partial class App : Application, ILiveView, IVMPusherApp
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true); var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>(); var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh(); sessionStatus.Refresh();
var homePageVm = provider.GetRequiredService<HomePageViewModel>(); var homePage = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!; var app = (App)Current!;
await app.PushPageAsync(homePageVm); await app.PushPageAsync(homePage);
} }
/// <summary> /// <summary>
@ -317,31 +205,8 @@ public partial class App : Application, ILiveView, IVMPusherApp
await View!.NavRoot.PopAsync(); await View!.NavRoot.PopAsync();
} }
internal static void UseConfigFileWhenLoading(string configFile) internal void UseConfigFileWhenLoading(string configFile)
{ {
CliConfigFileSpecification = configFile; this.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;
} }
} }

View file

@ -4,21 +4,15 @@ using Mono.Options;
using PostIt; using PostIt;
using PostIt.ViewModels; using PostIt.ViewModels;
namespace PostIt.CLIOptions;
public static class CLIOptionHandler public static class CLIOptionHandler
{ {
public static void HandleArgs(string[] args) public static void HandleArgs(string[] args)
{ {
bool showHelp = false; bool showHelp = false;
string configFile = null; string configFile = null;
bool liveMode = false;
var options = new OptionSet { var options = new OptionSet {
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v }, { "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 } { "h|help", "Show this message and exit", v => showHelp = true }
}; };
@ -32,11 +26,8 @@ public static class CLIOptionHandler
} }
if (configFile != null) if (configFile != null)
{ {
App.UseConfigFileWhenLoading(configFile); var app = App.Current as App;
} app.UseConfigFileWhenLoading(configFile);
if (liveMode)
{
App.EnableLiveMode();
} }
} }
} }

View file

@ -1,56 +0,0 @@
<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">
<Border
BorderBrush="LightGray"
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding !IsPreviewVisible, ElementName=Root}"
Padding="0">
<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" />
</Border>
<Border
BorderBrush="LightGray"
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
Padding="8">
<ScrollViewer
x:Name="PreviewScrollViewer"
BringIntoViewOnFocusChange="False"
VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Auto">
<md:MarkdownViewer
x:Name="PreviewViewer"
HorizontalContentAlignment="Stretch"
VerticalAlignment="Top"
ClipToBounds="False"
UseLayoutRounding="True"
MarkdownText="{Binding MarkdownText, ElementName=Root}" />
</ScrollViewer>
</Border>
</Grid>
</UserControl>

View file

@ -1,63 +0,0 @@
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;
}
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;
}
}
}
}

View file

@ -10,19 +10,13 @@
Padding="8"> Padding="8">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Glyph}" <TextBlock Text="{Binding Glyph}"
FontWeight="Bold" FontWeight="Bold"
Foreground="{Binding Foreground}" Foreground="{Binding Foreground}"
VerticalAlignment="Top"/> VerticalAlignment="Top"/>
<SelectableTextBlock Text="{Binding Message}" <TextBlock Text="{Binding Message}"
Foreground="{Binding Foreground}" Foreground="{Binding Foreground}"
TextWrapping="Wrap" TextWrapping="Wrap"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="{Binding CheckCommandLabel}"
Foreground="{Binding Foreground}"
VerticalAlignment="Center"
IsVisible="{Binding HasCheckCommand}"
Command="{Binding CheckCommand}"
/>
</StackPanel> </StackPanel>
</Border> </Border>
</UserControl> </UserControl>

View file

@ -2,10 +2,8 @@ using System;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.ViewModels.Chat;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Blogs; using PostIt.Views.Blogs;
using PostIt.Views.Chat;
using PostIt.Views.Commands; using PostIt.Views.Commands;
using Yavsc.Api.Client; using Yavsc.Api.Client;
@ -13,7 +11,7 @@ namespace PostIt.Helpers;
public static class ServiceCollectionHelpers public static class ServiceCollectionHelpers
{ {
public static IServiceProvider BuildPostItServices(this ServiceCollection services) public static IServiceProvider BuildServices(this ServiceCollection services)
{ {
var settings = new Settings(); var settings = new Settings();
settings.Load(); settings.Load();
@ -35,19 +33,13 @@ public static class ServiceCollectionHelpers
var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl); var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl); var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService(); var reverseGeocoding = new NominatimReverseGeocodingService();
// Vues // Vues
services.AddTransient<MainWindow>(); services.AddSingleton<MainView>();
services.AddTransient<MainView>(); services.AddSingleton<BlogsPage>();
services.AddTransient<BlogsPage>(); services.AddSingleton<MainWindow>();
// Models
services.AddSingleton<ChatViewModel>();
// SettingsPage is a singleton: there must be one and only one // SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app. // instance of the settings UI for the lifetime of the app.
@ -61,13 +53,11 @@ public static class ServiceCollectionHelpers
// the navigation stack, each bound to a fresh // the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits. // SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>(); services.AddSingleton<SettingsPage>();
services.AddSingleton<AboutPage>();
services.AddSingleton<HomePage>(); services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>(); services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>(); services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>(); services.AddSingleton<ActivitiesPage>();
services.AddTransient<CommandFormsPage>(); services.AddTransient<CommandFormsPage>();
services.AddTransient<ChatPage>();
services.AddTransient<RdvPage>(); services.AddTransient<RdvPage>();
services.AddTransient<BrushPage>(); services.AddTransient<BrushPage>();
services.AddTransient<BillingQueriesPage>(); services.AddTransient<BillingQueriesPage>();
@ -77,10 +67,6 @@ public static class ServiceCollectionHelpers
services.AddTransient<EstimateListPage>(); services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>(); services.AddTransient<EstimateValidationPage>();
services.AddTransient<MyFilesPage>(); services.AddTransient<MyFilesPage>();
services.AddTransient<UserProfilePage>();
services.AddTransient<PerformerConfigurationPage>();
services.AddTransient<AdministrationPage>();
services.AddTransient<ResourceUsagePage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
@ -93,16 +79,10 @@ public static class ServiceCollectionHelpers
services.AddSingleton(billingClient); services.AddSingleton(billingClient);
services.AddSingleton(estimateClient); services.AddSingleton(estimateClient);
services.AddSingleton(frontClient); services.AddSingleton(frontClient);
services.AddSingleton(resourceUsageClient);
services.AddSingleton(notificationsClient);
services.AddSingleton(userFilesClient); services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding); services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<AboutPageViewModel>();
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();
services.AddTransient<UserProfilePageViewModel>();
services.AddTransient<PerformerConfigurationPageViewModel>();
services.AddTransient<AdministrationPageViewModel>();
services.AddSingleton<SignaturePageViewModel>(); services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>(); services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>(); services.AddSingleton<ActivitiesPageViewModel>();

View file

@ -0,0 +1,53 @@
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;
}
}

View file

@ -1,10 +0,0 @@
using System.Threading.Tasks;
using PostIt.ViewModels;
using Avalonia.Controls;
namespace PostIt;
internal interface IVMPusherApp
{
Task<Page> PushPageAsync(ViewModelBase viewModel);
}

View file

@ -1,6 +0,0 @@
using System;
public interface IPCMProcessor : IDisposable
{
void Process(short[] audioFrame);
}

View file

@ -1,72 +0,0 @@
//------------------------------------------------------------------------------
// <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);
}
}
}
}

View file

@ -1,5 +0,0 @@
<root>
<data name="GreetingText">
<value>Welcome to PostIt!</value>
</data>
</root>

View file

@ -1,5 +0,0 @@
<root>
<data name="GreetingText">
<value>Bemvindo em PostIt!</value>
</data>
</root>

View file

@ -1,5 +0,0 @@
<root>
<data name="GreetingText">
<value>Bienvenue dans PostIt !!!</value>
</data>
</root>

View file

@ -1,15 +0,0 @@
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; }
}
}

View file

@ -1,14 +0,0 @@
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; }
}
}

View file

@ -1,11 +0,0 @@
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();
}
}

View file

@ -1,6 +0,0 @@
namespace PostIt.ViewModels;
internal interface IWithLabel
{
string Label { get; }
}

View file

@ -1,8 +0,0 @@
namespace PostIt.Models;
public enum StatusSeverity
{
Info,
Warning,
Error
}

View file

@ -7,8 +7,6 @@
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
@ -32,36 +30,16 @@
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="LibVLCSharp.Avalonia" />
<PackageReference Include="Live.Avalonia" />
<PackageReference Include="Mapsui" /> <PackageReference Include="Mapsui" />
<PackageReference Include="Mapsui.Avalonia12" /> <PackageReference Include="Mapsui.Avalonia12" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" /> <PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" /> <PackageReference Include="Mono.Options" />
<PackageReference Include="PvRecorder" />
<PackageReference Include="Silk.NET.OpenAL" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" /> <ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> </Project>
<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>

View file

@ -1,67 +0,0 @@
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();
}
}

View file

@ -39,16 +39,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
private readonly SemaphoreSlim _refreshGate = new(1, 1); private readonly SemaphoreSlim _refreshGate = new(1, 1);
private RefreshTokenRecord? _tokens; private RefreshTokenRecord? _tokens;
private static readonly HttpRequestOptionsKey<bool> AnonymousRequest = new("PostIt.AnonymousRequest");
public virtual async Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Options.Set(AnonymousRequest, true);
using var response = await Http.SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
}
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null) public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{ {
@ -59,16 +49,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_bearer = new BearerTokenHandler(this); _bearer = new BearerTokenHandler(this);
Http = new HttpClient(_bearer, disposeHandler: true); Http = new HttpClient(_bearer, disposeHandler: true);
// HTTP/3 (QUIC) vers les APIs Yavsc : le edge nginx termine h3 sur
// tous les vhosts (listen 443 quic ; http3 on ; Alt-Svc h3=":443").
// RequestVersionOrLower négocie h3 quand le serveur ET la plateforme
// le permettent (Desktop avec libmsquic), et retombe sur h2/h1.1
// sinon — notamment WASM, où le navigateur n'expose pas QUIC au
// fetch, et Android si libmsquic n'est pas disponible. Tous les
// clients Yavsc.Api.Client partagent cette même instance `Http`.
Http.DefaultRequestVersion = new Version(3, 0);
Http.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
_tokens = store.Load(); _tokens = store.Load();
} }
@ -324,7 +304,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
// Server rejected: could be revocation, clock skew, audience mismatch. // Server rejected: could be revocation, clock skew, audience mismatch.
// Force a refresh and retry exactly once. // Force a refresh and retry exactly once.
response.Dispose(); response.Dispose();
await ForceRefreshAsync(ct, req.Headers.Authorization?.Parameter).ConfigureAwait(false); await ForceRefreshAsync(ct).ConfigureAwait(false);
using var retry = new HttpRequestMessage(method, path); using var retry = new HttpRequestMessage(method, path);
if (contentFactory is not null) if (contentFactory is not null)
@ -389,46 +369,13 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew) if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
return; return;
await ForceRefreshAsync(ct).ConfigureAwait(false);
}
private async Task ForceRefreshAsync(CancellationToken ct, string? rejectedAccessToken = null)
{
await _refreshGate.WaitAsync(ct).ConfigureAwait(false); await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
try try
{ {
var tokens = _tokens; if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
if (tokens is null || string.IsNullOrEmpty(tokens.RefreshToken))
throw new InvalidOperationException("Session expirée. Veuillez vous reconnecter.");
if (rejectedAccessToken is null
? tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew
: !string.Equals(tokens.AccessToken, rejectedAccessToken, StringComparison.Ordinal))
return; return;
var result = await _oidc.RefreshTokenAsync(tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false); await ForceRefreshAsync(ct).ConfigureAwait(false);
if (result.IsError)
{
var isPermanent = result.Error is "invalid_grant" or "invalid_client";
if (isPermanent)
{
_store.Clear();
_tokens = null;
}
throw new RefreshFailedException(
isPermanent
? $"Session expirée. Veuillez vous reconnecter ({result.Error})."
: $"Impossible de renouveler la session : {result.Error ?? "refresh failed"}.",
isPermanent);
}
_tokens = new RefreshTokenRecord(
AccessToken: result.AccessToken!,
RefreshToken: result.RefreshToken ?? tokens.RefreshToken,
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
IdToken: result.IdentityToken ?? tokens.IdToken);
_store.Save(_tokens);
} }
finally finally
{ {
@ -436,6 +383,30 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
} }
} }
private async Task ForceRefreshAsync(CancellationToken ct)
{
if (_tokens is null || string.IsNullOrEmpty(_tokens.RefreshToken))
throw new InvalidOperationException("No refresh token available.");
var result = await _oidc.RefreshTokenAsync(_tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
if (result.IsError)
{
// Refresh token dead: revoked, expired, or rotation-theft
// detected. Purge and force an interactive re-login.
_store.Clear();
_tokens = null;
throw new RefreshFailedException(result.Error ?? "refresh failed", isPermanent: true);
}
_tokens = new RefreshTokenRecord(
AccessToken: result.AccessToken!,
RefreshToken: result.RefreshToken ?? _tokens.RefreshToken,
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
IdToken: result.IdentityToken ?? _tokens.IdToken);
_store.Save(_tokens);
}
/// <summary> /// <summary>
/// Upload a user avatar to the Yavsc API. The server expects a /// Upload a user avatar to the Yavsc API. The server expects a
/// single multipart file named <c>file</c> and validates the image /// single multipart file named <c>file</c> and validates the image
@ -478,9 +449,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var response = await attemptUpload().ConfigureAwait(false); var response = await attemptUpload().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized) if (response.StatusCode == HttpStatusCode.Unauthorized)
{ {
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
response.Dispose(); response.Dispose();
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false); await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await attemptUpload().ConfigureAwait(false); response = await attemptUpload().ConfigureAwait(false);
} }
@ -534,9 +504,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var response = await sendOnce().ConfigureAwait(false); var response = await sendOnce().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized) if (response.StatusCode == HttpStatusCode.Unauthorized)
{ {
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
response.Dispose(); response.Dispose();
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false); await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await sendOnce().ConfigureAwait(false); response = await sendOnce().ConfigureAwait(false);
} }
@ -624,8 +593,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
{ {
if (_owner._tokens is not null if (_owner._tokens is not null)
&& !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous))
request.Headers.Authorization = new AuthenticationHeaderValue( request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", _owner._tokens.AccessToken); "Bearer", _owner._tokens.AccessToken);
return base.SendAsync(request, cancellationToken); return base.SendAsync(request, cancellationToken);

View file

@ -5,10 +5,8 @@ using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.ViewModels.Commands; using PostIt.ViewModels.Commands;
using PostIt.ViewModels.Chat;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Blogs; using PostIt.Views.Blogs;
using PostIt.Views.Chat;
using PostIt.Views.Commands; using PostIt.Views.Commands;
namespace PostIt; namespace PostIt;
@ -43,7 +41,6 @@ public class ViewLocator : IDataTemplate
{ {
BlogsViewModel => services.GetRequiredService<BlogsPage>(), BlogsViewModel => services.GetRequiredService<BlogsPage>(),
Settings => services.GetRequiredService<SettingsPage>(), Settings => services.GetRequiredService<SettingsPage>(),
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(), HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(), ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(), CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
@ -60,11 +57,6 @@ public class ViewLocator : IDataTemplate
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(), EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(), EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(), MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
UserProfilePageViewModel => services.GetRequiredService<UserProfilePage>(),
PerformerConfigurationPageViewModel => services.GetRequiredService<PerformerConfigurationPage>(),
AdministrationPageViewModel => services.GetRequiredService<AdministrationPage>(),
ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(),
ChatViewModel => services.GetRequiredService<ChatPage>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -5,7 +5,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Models; using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;

View file

@ -12,7 +12,6 @@ using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot; using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using System.Net.Http; using System.Net.Http;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -8,7 +8,6 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -12,7 +12,6 @@ using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -388,4 +388,4 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
QueryStatus.Success => "V", QueryStatus.Success => "V",
_ => "i", _ => "i",
}; };
} }

View file

@ -6,7 +6,6 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -10,7 +10,6 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -9,7 +9,6 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -13,7 +13,6 @@ using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -1,99 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Identity;
namespace PostIt.ViewModels;
public partial class AdministrationPageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial ObservableCollection<UserInfo> Users { get; set; } = new();
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement…";
[ObservableProperty]
public partial UserInfo? SelectedUser { get; set; }
public IReadOnlyList<UserInfo> FilteredUsers =>
string.IsNullOrWhiteSpace(SearchText)
? Users.ToList()
: Users.Where(user =>
(user.UserName ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.FullName ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.Email ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.UserId ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase))
.ToList();
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public AdministrationPageViewModel(YavscApiClient? api)
{
_api = api;
}
public AdministrationPageViewModel() : this(null) { }
public Task InitializeAsync() => RefreshAsync();
partial void OnSearchTextChanged(string value)
{
OnPropertyChanged(nameof(FilteredUsers));
}
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null)
{
StatusMessage = "Client API d’administration indisponible.";
return;
}
IsBusy = true;
try
{
var query = SearchText.Trim();
var usersEndpoint = string.IsNullOrWhiteSpace(query)
? new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "users").ToString()
: new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), $"users/search/{Uri.EscapeDataString(query)}").ToString();
var users = await _api.CallAsync<List<UserInfo>>(HttpMethod.Get, usersEndpoint).ConfigureAwait(true);
Users.Clear();
foreach (var user in users ?? [])
{
Users.Add(user);
}
OnPropertyChanged(nameof(FilteredUsers));
StatusMessage = Users.Count > 0 ? $"{Users.Count} utilisateur(s) chargé(s)." : "Aucun utilisateur trouvé.";
}
catch (Exception ex)
{
Users.Clear();
OnPropertyChanged(nameof(FilteredUsers));
StatusMessage = $"Accès administration refusé ou indisponible : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,7 +1,6 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
@ -11,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Files; using Yavsc.Abstract.Files;
using Yavsc.Server.Helpers; using PostIt.Helpers;
using AvaloniaEdit.Document; using AvaloniaEdit.Document;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -39,66 +38,18 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// <summary>Editor buffer for the post body. Same pattern as /// <summary>Editor buffer for the post body. Same pattern as
/// <see cref="DraftTitle"/>.</summary> /// <see cref="DraftTitle"/>.</summary>
public string? DraftArticle public string DraftArticle
{ {
get => DraftArticleDocument?.Text ?? null; get => DraftArticleDocument.Text;
set set {
{
var currentText = DraftArticleDocument?.Text;
if (string.Equals(currentText, value, StringComparison.Ordinal))
{
OnPropertyChanged(nameof(DraftArticle));
return;
}
OnPropertyChanging(nameof(DraftArticle)); OnPropertyChanging(nameof(DraftArticle));
try DraftArticleDocument = new TextDocument(value);
{ this.OnPropertyChanged(nameof(DraftArticle));
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));
} }
} }
private TextDocument? _draftArticleDocument; [ObservableProperty]
public partial TextDocument DraftArticleDocument {get; set;}
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));
UpdateDraftModified();
}
}
private void DraftArticleDocument_TextChanged(object? sender, EventArgs e)
{
OnPropertyChanged(nameof(DraftArticle));
UpdateDraftModified();
}
/// <summary>Editor buffer for the post's publication state. /// <summary>Editor buffer for the post's publication state.
/// Reflects the server-side <c>IsPublished</c> flag (the /// Reflects the server-side <c>IsPublished</c> flag (the
@ -111,14 +62,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// mutable field. Toggling is its own action.</summary> /// mutable field. Toggling is its own action.</summary>
[ObservableProperty] [ObservableProperty]
public partial bool DraftIsPublished { get; set; } public partial bool DraftIsPublished { get; set; }
[ObservableProperty]
public partial bool IsDraftModified { get; private set; }
private bool _preserveDraft;
private string _savedTitle = string.Empty;
private string _savedArticle = string.Empty;
private readonly Dictionary<string, string> _pendingAttachmentLinks = new();
public bool IsLoaded { get; private set; } public bool IsLoaded { get; private set; }
public Settings SettingsModel { get; } public Settings SettingsModel { get; }
@ -168,7 +111,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
}); });
} }
[RelayCommand(CanExecute = nameof(CanSave))] [RelayCommand]
internal async Task SaveAsync() internal async Task SaveAsync()
{ {
// The button is already disabled when the title is empty // The button is already disabled when the title is empty
@ -186,6 +129,16 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
{ {
var attachments = DraftAttachments.ToArray(); var attachments = DraftAttachments.ToArray();
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behavior copied the buffer
// (which was a no-op when SelectedPost was null)
// back onto the model and relied on a
// [Required] violation to surface the missing
// input; the new shape keeps the editor buffer as
// the single source of truth for outgoing payloads
// and the selected post as a read-only hint for
// the update path.
if (SelectedPost is null || SelectedPost.Id == 0) if (SelectedPost is null || SelectedPost.Id == 0)
{ {
var draft = new BlogPostDto var draft = new BlogPostDto
@ -196,22 +149,12 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
IsPublished = DraftIsPublished IsPublished = DraftIsPublished
}; };
var savedTitle = draft.Title;
var savedArticle = draft.Article;
var created = await BlogClient!.CreatePostAsync(draft, attachments); var created = await BlogClient!.CreatePostAsync(draft, attachments);
if (created is not null) if (created is not null)
{ {
_preserveDraft = true; SelectedPost = created;
try
{
SelectedPost = created;
}
finally
{
_preserveDraft = false;
}
if (ResolvePendingAttachmentLinks(created)) if (TryAppendAttachmentLinks(created, attachments))
{ {
var linkUpdate = new BlogPostDto var linkUpdate = new BlogPostDto
{ {
@ -223,22 +166,16 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateCreated = created.DateCreated, DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
}; };
savedTitle = linkUpdate.Title;
savedArticle = linkUpdate.Article;
await BlogClient.UpdatePostAsync(created.Id, linkUpdate); await BlogClient.UpdatePostAsync(created.Id, linkUpdate);
} }
created.Title = savedTitle;
created.Article = savedArticle;
this.SetInfoStatus($"Billet {created.Id} créé."); this.SetInfoStatus($"Billet {created.Id} créé.");
DraftAttachments.Clear(); DraftAttachments.Clear();
MarkDraftSaved(savedTitle, savedArticle);
} }
else
throw new InvalidOperationException("Le serveur n'a pas retourné le billet créé.");
} }
else else
{ {
TryAppendAttachmentLinks(SelectedPost, attachments);
var update = new BlogPostDto var update = new BlogPostDto
{ {
Id = SelectedPost.Id, Id = SelectedPost.Id,
@ -250,11 +187,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
}; };
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments); await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
SelectedPost.Title = update.Title;
SelectedPost.Article = update.Article;
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré."); this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
DraftAttachments.Clear(); DraftAttachments.Clear();
MarkDraftSaved(update.Title, update.Article);
} }
await RefreshPostsAsync(); await RefreshPostsAsync();
@ -450,7 +384,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
Posts = new ObservableCollection<BlogPostDto>(); Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>(); FilteredPosts = new ObservableCollection<BlogPostDto>();
DraftAttachments = new ObservableCollection<BlogUploadFile>(); DraftAttachments = new ObservableCollection<BlogUploadFile>();
DraftAttachments.CollectionChanged += DraftAttachments_CollectionChanged;
SelectedPost = null; SelectedPost = null;
IsBusy = false; IsBusy = false;
this.SetInfoStatus("Prêt."); this.SetInfoStatus("Prêt.");
@ -462,7 +395,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DraftArticle = string.Empty; DraftArticle = string.Empty;
DraftIsPublished = false; DraftIsPublished = false;
MarkDraftSaved();
IsLoaded = false; IsLoaded = false;
// Production path: DI injects the canonical Settings singleton // Production path: DI injects the canonical Settings singleton
// and we use it as-is. Test path: tests call this constructor // and we use it as-is. Test path: tests call this constructor
@ -523,7 +455,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
partial void OnSelectedPostChanged(BlogPostDto? value) partial void OnSelectedPostChanged(BlogPostDto? value)
{ {
if (_preserveDraft) return;
// Mirror the selection into the editor buffer so the // Mirror the selection into the editor buffer so the
// XAML-bound TextBox/TextEditor show the right content // XAML-bound TextBox/TextEditor show the right content
// when the user clicks a post in the list. When the // when the user clicks a post in the list. When the
@ -537,8 +468,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
// null selection so a fresh draft starts unpublished. // null selection so a fresh draft starts unpublished.
DraftIsPublished = value?.IsPublished ?? false; DraftIsPublished = value?.IsPublished ?? false;
DraftAttachments.Clear(); DraftAttachments.Clear();
_pendingAttachmentLinks.Clear();
MarkDraftSaved();
UpdateCommandStates(); UpdateCommandStates();
} }
@ -547,28 +476,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
// Save's CanExecute depends on the buffer: the button must // Save's CanExecute depends on the buffer: the button must
// enable as soon as the user has typed a non-whitespace // enable as soon as the user has typed a non-whitespace
// title, regardless of whether a post is selected. // title, regardless of whether a post is selected.
partial void OnDraftTitleChanged(string value) partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
{
SaveCommand.NotifyCanExecuteChanged();
UpdateDraftModified();
}
private void UpdateDraftModified()
{
IsDraftModified = !string.Equals(DraftTitle ?? string.Empty, _savedTitle, StringComparison.Ordinal)
|| !string.Equals(DraftArticle ?? string.Empty, _savedArticle, StringComparison.Ordinal)
|| (DraftAttachments?.Count ?? 0) > 0;
}
private void MarkDraftSaved()
=> MarkDraftSaved(DraftTitle, DraftArticle);
private void MarkDraftSaved(string? title, string? article)
{
_savedTitle = title ?? string.Empty;
_savedArticle = article ?? string.Empty;
UpdateDraftModified();
}
private async Task RefreshPostsAsync() private async Task RefreshPostsAsync()
@ -581,6 +489,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
} }
ApplyFilter(); ApplyFilter();
if (SelectedPost is not null)
{
SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost;
}
} }
private void ApplyFilter() private void ApplyFilter()
@ -595,29 +507,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) || p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
.OrderByDescending(p => p.DateModified); .OrderByDescending(p => p.DateModified);
var selection = SelectedPost; FilteredPosts.Clear();
var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray(); foreach (var post in filtered)
_preserveDraft = true;
try
{ {
for (var i = FilteredPosts.Count - 1; i >= 0; i--) FilteredPosts.Add(post);
{
if (!visiblePosts.Contains(FilteredPosts[i]))
FilteredPosts.RemoveAt(i);
}
for (var i = 0; i < visiblePosts.Length; i++)
{
var previousIndex = FilteredPosts.IndexOf(visiblePosts[i]);
if (previousIndex < 0)
FilteredPosts.Insert(i, visiblePosts[i]);
else if (previousIndex != i)
FilteredPosts.Move(previousIndex, i);
}
SelectedPost = selection;
}
finally
{
_preserveDraft = false;
} }
} }
@ -656,53 +549,40 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
} }
} }
private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> attachments)
{ {
if (e.NewItems is not null) if (attachments.Count == 0)
{ return false;
foreach (BlogUploadFile attachment in e.NewItems)
{
var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName);
string fileUrl;
if (SelectedPost is { Id: > 0 } post)
fileUrl = AttachmentUrl(post, storedName);
else
{
fileUrl = $"postit-attachment:{Guid.NewGuid():N}";
_pendingAttachmentLinks.Add(fileUrl, storedName);
}
var article = DraftArticle ?? string.Empty;
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
if (!article.Contains(markdownLine, StringComparison.Ordinal))
{
var prefix = article.Length == 0 ? ""
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
DraftArticle = article + prefix + markdownLine;
}
}
}
UpdateDraftModified();
}
private string AttachmentUrl(BlogPostDto post, string storedName) var ownerSegment = post.Author?.UserName;
{ if (string.IsNullOrWhiteSpace(ownerSegment))
var owner = post.Author?.UserName ?? post.AuthorId; ownerSegment = post.AuthorId;
if (string.IsNullOrWhiteSpace(owner))
throw new InvalidOperationException("Le propriétaire du billet est nécessaire au lien du fichier."); if (string.IsNullOrWhiteSpace(ownerSegment))
return ResolveUserFileUrl( return false;
$"{EscapePathSegment(owner)}/blogs/{post.Id}/{EscapePathSegment(storedName)}");
}
private bool ResolvePendingAttachmentLinks(BlogPostDto post)
{
var article = DraftArticle ?? string.Empty; var article = DraftArticle ?? string.Empty;
foreach (var link in _pendingAttachmentLinks) var links = new List<string>();
article = article.Replace($"({link.Key})", $"({AttachmentUrl(post, link.Value)})",
StringComparison.Ordinal); foreach (var attachment in attachments)
var changed = article != DraftArticle; {
if (changed) DraftArticle = article; var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}";
_pendingAttachmentLinks.Clear(); var fileUrl = ResolveUserFileUrl(relativePath);
return changed; var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
if (!article.Contains(markdownLine, StringComparison.Ordinal))
links.Add(markdownLine);
}
if (links.Count == 0)
return false;
var prefix = article.Length == 0
? ""
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
DraftArticle = article + prefix + string.Join("\n", links);
return true;
} }
private string ResolveUserFileUrl(string relativePath) private string ResolveUserFileUrl(string relativePath)
@ -711,7 +591,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
if (!string.IsNullOrWhiteSpace(authority) if (!string.IsNullOrWhiteSpace(authority)
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri)) && Uri.TryCreate(authority, UriKind.Absolute, out var baseUri))
{ {
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).AbsoluteUri; return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString();
} }
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}"; return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";

View file

@ -1,357 +0,0 @@
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 += async _ =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
IsConnected = true;
Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
});
// La reconnexion crée une nouvelle ConnectionId côté
// serveur : l'appartenance aux salons est perdue, il
// faut rejoindre à nouveau le salon courant.
if (!string.IsNullOrWhiteSpace(CurrentRoom))
await JoinRoomAsync();
};
_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}");
}
}
}
}

View file

@ -1,33 +0,0 @@
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();
}
}
}
}

View file

@ -2,7 +2,6 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Models;
using Yavsc; using Yavsc;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -6,7 +6,6 @@ using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using PostIt.Models;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Models.Billing; using Yavsc.Models.Billing;

View file

@ -1,77 +0,0 @@
using System;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace PostIt.ViewModels;
/// <summary>
/// ViewModel de la page « À propos de PostIt ». Page statique :
/// version de l'application, description, licence et liens vers le
/// dépôt Forgejo et son suivi de tickets.
/// </summary>
public partial class AboutPageViewModel : ViewModelBase
{
public const string RepositoryUrl = "https://forgejo.pschneider.fr/notazof/yavsc";
public const string IssuesUrl = RepositoryUrl + "/issues";
public AboutPageViewModel()
{
Version = ReadVersion();
}
public string Version { get; }
public string Description { get; } =
"PostIt est le client de la plateforme Yavsc : blog, devis, " +
"demandes de prestations et fichiers personnels.";
public string License { get; } =
"Licence WTFPL — Do What The Fuck You Want To Public License, version 2";
public override bool CanNavigateNext
{
get => false;
protected set => throw new NotImplementedException();
}
public override bool CanNavigatePrevious
{
get => true;
protected set => throw new NotImplementedException();
}
[RelayCommand]
private Task OpenRepository() => OpenUrlAsync(RepositoryUrl);
[RelayCommand]
private Task OpenIssues() => OpenUrlAsync(IssuesUrl);
private static async Task OpenUrlAsync(string url)
{
var app = (App)App.Current!;
var topLevel = TopLevel.GetTopLevel(app.View);
if (topLevel is not null)
{
await topLevel.Launcher.LaunchUriAsync(new Uri(url)).ConfigureAwait(true);
}
}
private static string ReadVersion()
{
var assembly = typeof(AboutPageViewModel).Assembly;
var info = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (!string.IsNullOrEmpty(info))
{
// "1.1.0-estimate.1+364.Branch.x.Sha.y" → "1.1.0-estimate.1"
var plus = info.IndexOf('+');
return plus > 0 ? info[..plus] : info;
}
return assembly.GetName().Version?.ToString() ?? "inconnue";
}
}

View file

@ -1,5 +1,3 @@
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public interface IActionStatusViewModel public interface IActionStatusViewModel
@ -31,4 +29,4 @@ public static class ActionStatusViewModelExtensions
_ => StatusNotice.Info(normalizedMessage), _ => StatusNotice.Info(normalizedMessage),
}; };
} }
} }

View file

@ -1,16 +1,11 @@
using System; using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels.Chat;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase public class HomePageViewModel : ViewModelBase
@ -18,26 +13,6 @@ public class HomePageViewModel : ViewModelBase
public YavscApiClient Api { get; } public YavscApiClient Api { get; }
public Settings Settings { get; } public Settings Settings { get; }
public SessionStatusViewModel SessionStatus { get; } public SessionStatusViewModel SessionStatus { get; }
public ObservableCollection<Notification> Notifications { get; } = new();
private readonly NotificationsApiClient? _notificationsClient;
private int _notificationsLoadVersion;
private bool _isLoadingNotifications;
private string? _notificationsError;
private bool _notificationsLoaded;
public bool IsLoadingNotifications
{
get => _isLoadingNotifications;
private set => SetProperty(ref _isLoadingNotifications, value);
}
public string? NotificationsError
{
get => _notificationsError;
private set => SetProperty(ref _notificationsError, value);
}
public bool HasNoNotifications => _notificationsLoaded && Notifications.Count == 0;
private string _welcomeText = "Welcome to PostIt!"; private string _welcomeText = "Welcome to PostIt!";
public string WelcomeText public string WelcomeText
@ -49,16 +24,11 @@ public class HomePageViewModel : ViewModelBase
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); } public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus, public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
NotificationsApiClient notificationsClient)
{ {
Api = api; Api = api;
Settings = settings; Settings = settings;
SessionStatus = sessionStatus; SessionStatus = sessionStatus;
_notificationsClient = notificationsClient;
SessionStatus.PropertyChanged += OnSessionStatusChanged;
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
() => _notificationsClient is not null);
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync); OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync); OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
@ -66,78 +36,13 @@ public class HomePageViewModel : ViewModelBase
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync); OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
OpenProfile = new AsyncRelayCommand(OpenProfileAsync);
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync);
OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync);
OpenChat = new AsyncRelayCommand(OpenChatAsync);
} }
public IAsyncRelayCommand OpenChat { get; }
public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenProfile { get; }
public IAsyncRelayCommand OpenPerformerConfiguration { get; }
public IAsyncRelayCommand OpenAdministration { get; }
public IAsyncRelayCommand OpenResourceUsage { get; }
public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; } public IAsyncRelayCommand OpenProviderRequests { get; }
public IAsyncRelayCommand OpenClientEstimates { get; } public IAsyncRelayCommand OpenClientEstimates { get; }
public IAsyncRelayCommand OpenProviderEstimates { get; } public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; } public IAsyncRelayCommand OpenMyFiles { get; }
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
public async Task RefreshNotificationsAsync()
{
if (_notificationsClient is null)
throw new InvalidOperationException("Notifications client is not available.");
var version = ++_notificationsLoadVersion;
Notifications.Clear();
NotificationsError = null;
_notificationsLoaded = false;
OnPropertyChanged(nameof(HasNoNotifications));
IsLoadingNotifications = true;
try
{
var notifications = SessionStatus.IsLoggedIn
? await _notificationsClient.GetCurrentAsync()
: await _notificationsClient.GetPublicAsync();
if (version != _notificationsLoadVersion)
return;
if (notifications is null)
throw new InvalidOperationException("The notifications response is empty.");
foreach (var notification in notifications)
Notifications.Add(notification);
_notificationsLoaded = true;
}
catch (Exception ex)
{
if (version == _notificationsLoadVersion)
NotificationsError = $"Impossible de charger les notifications : {ex.Message}";
}
finally
{
if (version == _notificationsLoadVersion)
{
IsLoadingNotifications = false;
OnPropertyChanged(nameof(HasNoNotifications));
}
}
}
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn) || _notificationsClient is null)
return;
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
if (Dispatcher.UIThread.CheckAccess())
_ = RefreshNotificationsAsync();
else
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
}
private async Task OpenActivitiesAsync() private async Task OpenActivitiesAsync()
{ {
@ -198,14 +103,6 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm); await app.PushPageAsync(vm);
} }
private async Task OpenChatAsync()
{
var app = (App?)Application.Current;
var vm = app!.ServiceProvider?.GetRequiredService<ChatViewModel>();
await app.PushPageAsync(vm!);
}
/// <summary> /// <summary>
/// Ouvre la page « Mes fichiers » (espace de stockage personnel), /// Ouvre la page « Mes fichiers » (espace de stockage personnel),
/// servie par le host Blogs via <c>api/v1/fs</c>. /// servie par le host Blogs via <c>api/v1/fs</c>.
@ -229,79 +126,6 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm); await app.PushPageAsync(vm);
} }
private async Task OpenProfileAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<UserProfilePageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page profil indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenPerformerConfigurationAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<PerformerConfigurationPageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page performer indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenAdministrationAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<AdministrationPageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page administration indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenResourceUsageAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var resourceUsageClient = app.ServiceProvider?.GetRequiredService<ResourceUsageApiClient>();
if (resourceUsageClient is null)
{
throw new InvalidOperationException("Client usage indisponible.");
}
var vm = new ResourceUsagePageViewModel(resourceUsageClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary> /// <summary>
/// Avalonia designer constructor. Builds a self-contained VM /// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can /// with a freshly-constructed Settings so the XAML preview can
@ -311,7 +135,7 @@ public class HomePageViewModel : ViewModelBase
/// (thread-safe dispatcher marshalling on PropertyChanged) — a /// (thread-safe dispatcher marshalling on PropertyChanged) — a
/// designer-only duplicate instance is therefore harmless. /// designer-only duplicate instance is therefore harmless.
/// </summary> /// </summary>
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!) public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
{ {
} }

View file

@ -1,90 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ResourceUsagePageViewModel : ViewModelBase
{
private readonly ResourceUsageApiClient _client;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial bool IsAdminView { get; set; }
[ObservableProperty]
public partial ResourceUsageSummary? CurrentSummary { get; set; }
[ObservableProperty]
public partial ObservableCollection<ResourceUsageSummary> Overview { get; set; } = new();
public string Heading => IsAdminView ? "Vue d'administration des usages" : "Mon usage des ressources";
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public ResourceUsagePageViewModel(ResourceUsageApiClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
Overview.Clear();
try
{
var adminOverview = await _client.GetAdminOverviewAsync().ConfigureAwait(true);
if (adminOverview is { Count: > 0 })
{
foreach (var item in adminOverview)
{
Overview.Add(item);
}
CurrentSummary = adminOverview[0];
IsAdminView = true;
return;
}
}
catch (Exception)
{
// Non-admin users are not allowed on the admin route; fall back
// to the current-user summary below.
}
var current = await _client.GetCurrentAsync().ConfigureAwait(true);
CurrentSummary = current;
if (current is not null)
{
Overview.Clear();
Overview.Add(current);
}
IsAdminView = false;
}
catch (Exception ex)
{
CurrentSummary = null;
Overview.Clear();
IsAdminView = false;
throw new InvalidOperationException($"Impossible de charger l'usage des ressources : {ex.Message}", ex);
}
finally
{
IsBusy = false;
}
}
}

View file

@ -143,11 +143,4 @@ public partial class SessionStatusViewModel : ViewModelBase
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true); await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true);
} }
[RelayCommand]
internal async Task OpenAbout()
{
var app = (App)App.Current!;
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<AboutPageViewModel>()).ConfigureAwait(true);
}
} }

View file

@ -0,0 +1,35 @@
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);
}

View file

@ -1,612 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Identity;
namespace PostIt.ViewModels;
public partial class UserProfilePageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
private readonly SessionStatusViewModel? _sessionStatus;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveProfileCommand))]
[NotifyCanExecuteChangedFor(nameof(SetMonthlyEmailPreferenceCommand))]
[NotifyCanExecuteChangedFor(nameof(SetTwoFactorCommand))]
[NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))]
[NotifyCanExecuteChangedFor(nameof(AddBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveExternalLoginCommand))]
[NotifyCanExecuteChangedFor(nameof(SendEmailConfirmationCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadCalendarsCommand))]
[NotifyCanExecuteChangedFor(nameof(ChooseCalendarCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteAccountCommand))]
public partial bool IsBusy { get; set; }
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveProfileCommand))]
[NotifyCanExecuteChangedFor(nameof(SetMonthlyEmailPreferenceCommand))]
[NotifyCanExecuteChangedFor(nameof(SetTwoFactorCommand))]
[NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))]
[NotifyCanExecuteChangedFor(nameof(AddBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveExternalLoginCommand))]
[NotifyCanExecuteChangedFor(nameof(SendEmailConfirmationCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadCalendarsCommand))]
[NotifyCanExecuteChangedFor(nameof(ChooseCalendarCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteAccountCommand))]
public partial bool IsProfileLoaded { get; set; }
[ObservableProperty]
public partial string UserName { get; set; } = "—";
[ObservableProperty]
public partial string CurrentUserName { get; set; } = "—";
[ObservableProperty]
public partial string UserId { get; set; } = "—";
[ObservableProperty]
public partial string FullName { get; set; } = "—";
[ObservableProperty]
public partial string Email { get; set; } = "—";
[ObservableProperty]
public partial string Address { get; set; } = "—";
[ObservableProperty]
public partial string Avatar { get; set; } = "—";
[ObservableProperty]
public partial string Roles { get; set; } = "—";
[ObservableProperty]
public partial string DedicatedGoogleCalendar { get; set; } = "Non configuré";
[ObservableProperty]
public partial string BankInfoSummary { get; set; } = "Aucune information bancaire";
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial bool EmailConfirmed { get; set; }
[ObservableProperty]
public partial bool AllowMonthlyEmail { get; set; }
[ObservableProperty]
public partial bool HasPassword { get; set; }
[ObservableProperty]
public partial bool TwoFactorEnabled { get; set; }
[ObservableProperty]
public partial int ExternalLoginCount { get; set; }
[ObservableProperty]
public partial long PostsCounter { get; set; }
[ObservableProperty]
public partial long DiskUsage { get; set; }
[ObservableProperty]
public partial long DiskQuota { get; set; }
[ObservableProperty]
public partial decimal Credits { get; set; }
[ObservableProperty]
public partial string CurrentPassword { get; set; } = string.Empty;
[ObservableProperty]
public partial string NewPassword { get; set; } = string.Empty;
[ObservableProperty]
public partial string ConfirmPassword { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankIban { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankBic { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankCode { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankWicketCode { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankAccountNumber { get; set; } = string.Empty;
[ObservableProperty]
public partial string UsernameConfirmation { get; set; } = string.Empty;
[ObservableProperty]
public partial int BankedKey { get; set; }
public ObservableCollection<BankAccountInfo> BankAccounts { get; } = [];
public ObservableCollection<LinkedExternalLogin> ExternalLogins { get; } = [];
public ObservableCollection<CalendarOption> Calendars { get; } = [];
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public UserProfilePageViewModel(YavscApiClient? api, SessionStatusViewModel? sessionStatus = null)
{
_api = api;
_sessionStatus = sessionStatus;
}
public UserProfilePageViewModel() : this(null, null) { }
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null)
{
IsProfileLoaded = false;
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
IsProfileLoaded = false;
try
{
var me = await _api.CallAsync<Me>(HttpMethod.Get, new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString()).ConfigureAwait(true);
if (me is null)
{
throw new InvalidOperationException("Le serveur a renvoyé un profil vide.");
}
UserName = me.UserName ?? "—";
CurrentUserName = UserName;
UserId = me.UserId;
FullName = me.FullName ?? "—";
Email = me.Email ?? "—";
Address = me.Address ?? "—";
Avatar = string.IsNullOrWhiteSpace(me.Avatar) ? "Aucun avatar" : me.Avatar;
Roles = me.Roles is { Length: > 0 } ? string.Join(", ", me.Roles) : "Aucun rôle";
DedicatedGoogleCalendar = string.IsNullOrWhiteSpace(me.DedicatedGoogleCalendar) ? "Non configuré" : me.DedicatedGoogleCalendar;
BankInfoSummary = string.IsNullOrWhiteSpace(me.BankInfoSummary) ? "Aucune information bancaire" : me.BankInfoSummary;
EmailConfirmed = me.EmailConfirmed;
AllowMonthlyEmail = me.AllowMonthlyEmail;
HasPassword = me.HasPassword;
TwoFactorEnabled = me.TwoFactorEnabled;
ExternalLoginCount = me.ExternalLoginCount;
PostsCounter = me.PostsCounter;
DiskUsage = me.DiskUsage;
DiskQuota = me.DiskQuota;
Credits = me.Credits;
BankAccounts.Clear();
foreach (var account in me.BankAccounts)
{
BankAccounts.Add(account);
}
ExternalLogins.Clear();
foreach (var login in me.ExternalLogins)
{
ExternalLogins.Add(login);
}
IsProfileLoaded = true;
StatusMessage = "Profil chargé.";
}
catch (Exception ex)
{
IsProfileLoaded = false;
UserName = "—";
CurrentUserName = "—";
UserId = "—";
FullName = "—";
Email = "—";
Address = "—";
Avatar = "—";
Roles = "—";
DedicatedGoogleCalendar = "Non configuré";
BankInfoSummary = "Aucune information bancaire";
EmailConfirmed = false;
AllowMonthlyEmail = false;
HasPassword = false;
TwoFactorEnabled = false;
ExternalLoginCount = 0;
PostsCounter = 0;
DiskUsage = 0;
DiskQuota = 0;
Credits = 0;
BankAccounts.Clear();
ExternalLogins.Clear();
Calendars.Clear();
StatusMessage = $"Impossible de charger le profil : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public ProfileUpdateRequest BuildProfileUpdateRequest()
{
static string NormalizeField(string? value, string placeholder)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
return string.Equals(trimmed, placeholder, StringComparison.OrdinalIgnoreCase)
? string.Empty
: trimmed;
}
return new ProfileUpdateRequest
{
UserName = NormalizeField(UserName, "—"),
FullName = NormalizeField(FullName, "—"),
Address = NormalizeField(Address, "—"),
GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré")
};
}
private bool CanSaveProfile() => _api is not null && IsProfileLoaded && !IsBusy;
[RelayCommand(CanExecute = nameof(CanSaveProfile))]
public async Task SaveProfileAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
var payload = BuildProfileUpdateRequest();
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString();
await _api.CallAsync(HttpMethod.Put, endpoint, payload).ConfigureAwait(true);
StatusMessage = "Profil enregistré.";
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’enregistrer le profil : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task SetMonthlyEmailPreferenceAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
await UpdateBooleanPreferenceAsync(
"account/preferences/monthly-email",
new MonthlyEmailPreferenceRequest { Enabled = AllowMonthlyEmail },
enabled => AllowMonthlyEmail = enabled,
"préférence de courriel");
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task SetTwoFactorAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
await UpdateBooleanPreferenceAsync(
"account/security/two-factor",
new TwoFactorRequest { Enabled = TwoFactorEnabled },
enabled => TwoFactorEnabled = enabled,
"authentification à deux facteurs");
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task ChangePasswordAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
if (string.IsNullOrWhiteSpace(NewPassword)
|| !string.Equals(NewPassword, ConfirmPassword, StringComparison.Ordinal))
{
StatusMessage = "Le nouveau mot de passe et sa confirmation doivent être identiques.";
return;
}
IsBusy = true;
try
{
var endpoint = BuildEndpoint("account/security/password");
await _api.CallAsync(HttpMethod.Put, endpoint, new ChangePasswordRequest
{
CurrentPassword = CurrentPassword,
NewPassword = NewPassword,
ConfirmPassword = ConfirmPassword
}).ConfigureAwait(true);
CurrentPassword = string.Empty;
NewPassword = string.Empty;
ConfirmPassword = string.Empty;
HasPassword = true;
StatusMessage = "Mot de passe mis à jour.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de modifier le mot de passe : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task AddBankAccountAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
if (string.IsNullOrWhiteSpace(BankIban) && string.IsNullOrWhiteSpace(BankBic))
{
StatusMessage = "Renseignez un IBAN ou un BIC.";
return;
}
IsBusy = true;
try
{
var endpoint = BuildEndpoint("account/bank-info");
await _api.CallAsync<BankAccountInfo>(HttpMethod.Post, endpoint, new BankAccountInfoRequest
{
IBAN = BankIban.Trim(),
BIC = BankBic.Trim(),
BankCode = BankCode.Trim(),
WicketCode = BankWicketCode.Trim(),
AccountNumber = BankAccountNumber.Trim(),
BankedKey = BankedKey
}).ConfigureAwait(true);
BankIban = string.Empty;
BankBic = string.Empty;
BankCode = string.Empty;
BankWicketCode = string.Empty;
BankAccountNumber = string.Empty;
BankedKey = 0;
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’ajouter le compte bancaire : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task DeleteBankAccountAsync(BankAccountInfo? account)
{
if (_api is null || account is null)
{
return;
}
IsBusy = true;
try
{
await _api.CallAsync(HttpMethod.Delete, BuildEndpoint($"account/bank-info/{account.Id}")).ConfigureAwait(true);
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible de supprimer le compte bancaire : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task RemoveExternalLoginAsync(LinkedExternalLogin? login)
{
if (_api is null || login is null)
{
return;
}
IsBusy = true;
try
{
var query = $"provider={Uri.EscapeDataString(login.Provider)}&providerKey={Uri.EscapeDataString(login.ProviderKey)}";
await _api.CallAsync(HttpMethod.Delete, BuildEndpoint($"account/security/external-logins?{query}")).ConfigureAwait(true);
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible de supprimer cette connexion externe : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task SendEmailConfirmationAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
await _api.CallAsync<JsonElement>(HttpMethod.Post, BuildEndpoint("account/email/confirmation")).ConfigureAwait(true);
StatusMessage = "E-mail de confirmation envoyé.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’envoyer l’e-mail de confirmation : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task LoadCalendarsAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
var calendars = await _api.CallAsync<CalendarOption[]>(
HttpMethod.Get,
BuildEndpoint("account/calendars")).ConfigureAwait(true);
Calendars.Clear();
foreach (var calendar in calendars ?? [])
{
Calendars.Add(calendar);
}
StatusMessage = Calendars.Count == 0
? "Aucun agenda Google dont vous êtes propriétaire n’est disponible."
: "Agendas Google chargés.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de charger les agendas Google : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task ChooseCalendarAsync(CalendarOption? calendar)
{
if (calendar is null)
{
DedicatedGoogleCalendar = "Non configuré";
}
else
{
DedicatedGoogleCalendar = calendar.Id;
}
await SaveProfileAsync().ConfigureAwait(true);
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task DeleteAccountAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
if (!string.Equals(UsernameConfirmation, CurrentUserName, StringComparison.Ordinal))
{
StatusMessage = "Saisissez exactement votre nom d’utilisateur pour confirmer la suppression.";
return;
}
IsBusy = true;
try
{
await _api.CallAsync(HttpMethod.Delete, BuildEndpoint("account/me"), new DeleteAccountRequest
{
UsernameConfirmation = UsernameConfirmation
}).ConfigureAwait(true);
UsernameConfirmation = string.Empty;
StatusMessage = "Compte supprimé.";
if (_sessionStatus is not null)
{
await _sessionStatus.LogoutAsync().ConfigureAwait(true);
}
else
{
await _api.LogoutAsync().ConfigureAwait(true);
}
}
catch (Exception ex)
{
StatusMessage = $"Impossible de supprimer le compte : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private async Task UpdateBooleanPreferenceAsync<TRequest>(
string path,
TRequest request,
Action<bool> applyValue,
string preferenceName)
where TRequest : class
{
IsBusy = true;
try
{
var endpoint = BuildEndpoint(path);
var response = await _api!.CallAsync<System.Text.Json.JsonElement>(HttpMethod.Put, endpoint, request).ConfigureAwait(true);
var enabled = response.GetProperty("enabled").GetBoolean();
applyValue(enabled);
StatusMessage = $"Préférence mise à jour : {preferenceName}.";
}
catch (Exception ex)
{
await RefreshAsync().ConfigureAwait(true);
StatusMessage = $"Impossible de mettre à jour {preferenceName} : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private string BuildEndpoint(string path) =>
new Uri(new Uri(_api!.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), path).ToString();
private bool CanModifyAccount() => _api is not null && IsProfileLoaded && !IsBusy;
}

View file

@ -7,7 +7,6 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -227,4 +226,4 @@ public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
var idx = current.LastIndexOf('/'); var idx = current.LastIndexOf('/');
return idx < 0 ? string.Empty : current[..idx]; return idx < 0 ? string.Empty : current[..idx];
} }
} }

View file

@ -1,196 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ActivitySelectionOption : ObservableObject
{
public string Code { get; }
public string Name { get; }
[ObservableProperty]
public partial bool IsSelected { get; set; }
public ActivitySelectionOption(string code, string name)
{
Code = code;
Name = name;
}
}
public partial class PerformerConfigurationPageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
private readonly ActivityApiClient? _activityClient;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string PerformerId { get; set; } = string.Empty;
[ObservableProperty]
public partial string UserName { get; set; } = "—";
[ObservableProperty]
public partial string ExerciseCountryCode { get; set; } = "fr";
[ObservableProperty]
public partial string SIREN { get; set; } = string.Empty;
[ObservableProperty]
public partial string Website { get; set; } = string.Empty;
[ObservableProperty]
public partial bool Active { get; set; }
[ObservableProperty]
public partial bool AcceptNotifications { get; set; }
[ObservableProperty]
public partial bool AcceptPublicContact { get; set; }
[ObservableProperty]
public partial bool UseGeoLocalizationToReduceDistanceWithClients { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivitySelectionOption> AvailableActivities { get; set; } = new();
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public PerformerConfigurationPageViewModel(YavscApiClient? api, ActivityApiClient? activityClient)
{
_api = api;
_activityClient = activityClient;
}
public PerformerConfigurationPageViewModel() : this(null, null) { }
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null || _activityClient is null)
{
StatusMessage = "Client API de configuration indisponible.";
return;
}
IsBusy = true;
try
{
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString();
var dto = await _api.CallAsync<PerformerProfileSettings>(HttpMethod.Get, endpoint).ConfigureAwait(true);
PerformerId = dto?.PerformerId ?? string.Empty;
UserName = dto?.UserName ?? "—";
ExerciseCountryCode = string.IsNullOrWhiteSpace(dto?.ExerciseCountryCode) ? "fr" : dto.ExerciseCountryCode;
SIREN = dto?.SIREN ?? string.Empty;
Website = dto?.WebSite ?? string.Empty;
Active = dto?.Active ?? false;
AcceptNotifications = dto?.AcceptNotifications ?? false;
AcceptPublicContact = dto?.AcceptPublicContact ?? false;
UseGeoLocalizationToReduceDistanceWithClients = dto?.UseGeoLocalizationToReduceDistanceWithClients ?? false;
var catalog = await _activityClient.GetCatalogAsync().ConfigureAwait(true);
var options = Flatten(catalog)
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.Select(a => new ActivitySelectionOption(a.Code, a.Name)
{
IsSelected = dto?.SelectedActivityCodes.Contains(a.Code, StringComparer.OrdinalIgnoreCase) == true
})
.ToList();
AvailableActivities.Clear();
foreach (var option in options)
{
AvailableActivities.Add(option);
}
StatusMessage = "Profil performer chargé.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de charger la configuration performer : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public PerformerProfileSettings BuildPerformerProfileSettings()
{
return new PerformerProfileSettings
{
PerformerId = string.IsNullOrWhiteSpace(PerformerId) ? string.Empty : PerformerId,
UserName = UserName,
ExerciseCountryCode = string.IsNullOrWhiteSpace(ExerciseCountryCode) ? "fr" : ExerciseCountryCode,
SIREN = SIREN,
WebSite = Website,
Active = Active,
AcceptNotifications = AcceptNotifications,
AcceptPublicContact = AcceptPublicContact,
UseGeoLocalizationToReduceDistanceWithClients = UseGeoLocalizationToReduceDistanceWithClients,
SelectedActivityCodes = AvailableActivities
.Where(a => a.IsSelected)
.Select(a => a.Code)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
[RelayCommand]
public async Task SaveAsync()
{
if (_api is null)
{
StatusMessage = "Client API de configuration indisponible.";
return;
}
IsBusy = true;
try
{
var dto = BuildPerformerProfileSettings();
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString();
var response = await _api.CallAsync<PerformerProfileSettings>(HttpMethod.Put, endpoint, dto).ConfigureAwait(true);
StatusMessage = response is not null ? "Configuration performer enregistrée." : "Configuration performer enregistrée.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’enregistrer la configuration performer : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private static IEnumerable<ActivityInfo> Flatten(IEnumerable<ActivityInfo> activities)
{
foreach (var activity in activities)
{
yield return activity;
foreach (var child in Flatten(activity.Children ?? Enumerable.Empty<ActivityInfo>()))
{
yield return child;
}
}
}
}

View file

@ -8,8 +8,6 @@ using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Globalization;
using PostIt.Models;
[assembly: InternalsVisibleTo("PostIt.Tests")] [assembly: InternalsVisibleTo("PostIt.Tests")]
@ -46,7 +44,6 @@ public partial class Settings : ViewModelBase
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
[JsonIgnore]
public bool Loaded { get; private set; } = false; public bool Loaded { get; private set; } = false;
@ -62,16 +59,8 @@ public partial class Settings : ViewModelBase
/// <see cref="Authentication"/>. /// <see cref="Authentication"/>.
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
[JsonIgnore]
public partial bool IsDirty { get; private set; } = false; 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> /// <summary>
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c> /// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
@ -272,9 +261,10 @@ public partial class Settings : ViewModelBase
} }
var app = App.Current as App; var app = App.Current as App;
if (App.CliConfigFileSpecification!=null) if (app!=null)
if (app.CliConfigFileSpecification!=null)
{ {
SettingsFileFullName = App.CliConfigFileSpecification; SettingsFileFullName = app.CliConfigFileSpecification;
} }
if (SettingsFileFullName is not null) if (SettingsFileFullName is not null)
@ -542,4 +532,7 @@ public partial class Settings : ViewModelBase
/// </summary> /// </summary>
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged(); 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(); }
} }

View file

@ -1,40 +0,0 @@
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);
}

View file

@ -113,13 +113,13 @@
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1" <Button Grid.Column="1"
Content="Télécharger" Content="Télécharger"
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DownloadQueryAttachmentCommand}" Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DownloadQueryAttachmentCommand}"
CommandParameter="{Binding}" /> CommandParameter="{Binding}" />
<Button Grid.Column="3" <Button Grid.Column="3"
Content="Détacher" Content="Détacher"
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DetachQueryFileCommand}" Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DetachQueryFileCommand}"
CommandParameter="{Binding}" CommandParameter="{Binding}"
IsVisible="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).CanAttach}" /> IsVisible="{Binding $parent[vm:BillingQueryDetailsPageViewModel].CanAttach}" />
</Grid> </Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>

View file

@ -167,11 +167,11 @@
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1" <Button Grid.Column="1"
Content="Télécharger" Content="Télécharger"
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DownloadAttachmentCommand}" Command="{Binding $parent[vm:EstimateEditionPageViewModel].DownloadAttachmentCommand}"
CommandParameter="{Binding}" /> CommandParameter="{Binding}" />
<Button Grid.Column="3" <Button Grid.Column="3"
Content="Détacher" Content="Détacher"
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DetachFileCommand}" Command="{Binding $parent[vm:EstimateEditionPageViewModel].DetachFileCommand}"
CommandParameter="{Binding}" /> CommandParameter="{Binding}" />
</Grid> </Grid>
</Border> </Border>

View file

@ -1,234 +1,165 @@
<ContentPage <ContentPage xmlns="https://github.com/avaloniaui"
x:Class="PostIt.Views.Blogs.BlogsPage" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns="https://github.com/avaloniaui" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:vm="using:PostIt.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:postitControls="using:PostIt.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:md="using:MarkdownViewer.Core.Controls" xmlns:models="using:Yavsc.Blogspot"
xmlns:models="using:Yavsc.Blogspot" xmlns:views="using:PostIt.Views.Blogs"
xmlns:postitControls="using:PostIt.Controls" xmlns:md="using:MarkdownViewer.Core.Controls"
xmlns:views="using:PostIt.Views.Blogs" mc:Ignorable="d"
xmlns:vm="using:PostIt.ViewModels" x:Class="PostIt.Views.Blogs.BlogsPage"
HorizontalAlignment="Stretch" x:DataType="vm:BlogsViewModel"
VerticalAlignment="Stretch" HorizontalAlignment="Stretch"
x:DataType="vm:BlogsViewModel" VerticalAlignment="Stretch">
mc:Ignorable="d">
<Design.DataContext> <Design.DataContext>
<vm:BlogsViewModel /> <vm:BlogsViewModel />
</Design.DataContext> </Design.DataContext>
<ContentPage.Styles> <ScrollViewer
<Style Selector="CommandBarButton.modified"> VerticalScrollBarVisibility="Visible"
<Setter Property="Background" Value="#FFF0C2" /> HorizontalScrollBarVisibility="Disabled"
<Setter Property="Foreground" Value="#663C00" /> Padding="5"
<Setter Property="FontWeight" Value="Bold" /> Margin="5"
</Style> >
</ContentPage.Styles>
<Grid
RowDefinitions="Auto,Auto,*"
Margin="5,5,10,5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<WrapPanel x:Name="BlogsHeaderPanel"
Margin="5"
Orientation="Horizontal">
<TextBlock
FontSize="20"
FontWeight="SemiBold"
Text="Blog Posts" />
<TextBox
HorizontalAlignment="Right"
PlaceholderText="Filter by title or author..."
Text="{Binding SearchText, Mode=TwoWay}" />
</WrapPanel>
<CommandBar x:Name="BlogsCommandBar"
Grid.Row="1"
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
<CommandBar.PrimaryCommands>
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="SaveButton"
Command="{Binding SaveCommand}"
Classes.modified="{Binding IsDraftModified}"
IsEnabled="{Binding !IsBusy}"
Label="Save">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Command="{Binding DeleteAsync}" Label="Delete">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="AddAttachmentButton"
Click="AddAttachment_Click"
IsEnabled="{Binding !IsBusy}"
Label="Ajouter fichiers">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="ManageAclButton"
Command="{Binding ManageAclAsync}"
Label="ACL">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AclIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="OpenCirclesButton"
Command="{Binding OpenCirclesAsync}"
Label="Mes cercles">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource CircleIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Label="Publié">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton
x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDevAsync}"
Label="[DEV] Signature">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SignIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid"> <StackPanel HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="5">
<TextBlock Text="Blog Posts" FontSize="20" FontWeight="SemiBold" />
<TextBox HorizontalAlignment="Right"
Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Filter by title or author..." />
</StackPanel>
<CommandBar IsDynamicOverflowEnabled="True"
HorizontalAlignment="Right" >
<CommandBar.PrimaryCommands>
<CommandBarButton Label="Refresh" Command="{Binding RefreshAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="SaveButton" Label="Save" Command="{Binding SaveAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Label="Delete" Command="{Binding DeleteAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Label="Ajouter fichiers" Click="AddAttachment_Click"
x:Name="AddAttachmentButton">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="ManageAclButton"
Label="ACL"
Command="{Binding ManageAclAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AclIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="OpenCirclesButton"
Label="Mes cercles" Command="{Binding OpenCirclesAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource CircleIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton Label="Publié"
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarToggleButton Label="Markdown Viewer"
IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton x:Name="OpenSignatureDevButton"
Label="[DEV] Signature" Command="{Binding OpenSignatureDevAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SignIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Margin="12" RowSpacing="12">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="4" /> <RowDefinition Height="Auto" />
<RowDefinition Height="2*" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Border <Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
Grid.Row="0" <ListBox ItemsSource="{Binding FilteredPosts}"
Padding="8" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
BorderBrush="Gray"
BorderThickness="1">
<ListBox
x:Name="PostsListBox"
IsEnabled="{Binding !IsBusy}"
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto"> <DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4"> <StackPanel Spacing="4" >
<StackPanel Orientation="Horizontal" Spacing="8"> <TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock <TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
VerticalAlignment="Center" <TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
FontWeight="SemiBold"
Text="{Binding Title}" />
<Border
x:Name="PublishedBadge"
VerticalAlignment="Center"
Padding="6,2"
Background="#F2F7F2"
BorderBrush="#2F6D2F"
BorderThickness="1"
CornerRadius="6"
IsVisible="{Binding IsPublished}">
<TextBlock
FontSize="10"
FontWeight="SemiBold"
Foreground="#2F6D2F"
Text="Publié" />
</Border>
</StackPanel>
<TextBlock
FontSize="10"
Foreground="Gray"
Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" />
<TextBlock
FontSize="10"
Foreground="DarkSlateGray"
Text="{Binding AuthorId}" />
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
</Border> </Border>
<GridSplitter <md:MarkdownViewer Grid.Row="2" Name="MarkdownViewer"
Grid.Row="1" IsVisible="{Binding IsMarkdownViewerVisible}"
HorizontalAlignment="Stretch" MarkdownText="{Binding DraftArticle}" />
ResizeDirection="Rows" />
<Grid <Border Grid.Row="2" BorderBrush="Gray" BorderThickness="1" Padding="8"
Grid.Row="2" IsVisible="{Binding !IsMarkdownViewerVisible}">
HorizontalAlignment="Stretch" <Grid RowSpacing="10"
VerticalAlignment="Stretch" HorizontalAlignment="Stretch"
RowSpacing="10"> VerticalAlignment="Stretch">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> </Grid.RowDefinitions>
</Grid.RowDefinitions> <Grid.ColumnDefinitions>
<Grid.ColumnDefinitions> <ColumnDefinition Width="*" />
<ColumnDefinition Width="*" /> </Grid.ColumnDefinitions>
</Grid.ColumnDefinitions>
<TextBlock <TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
Grid.Row="0" <TextBox x:Name="DraftTitleTextBox"
FontWeight="SemiBold" Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
Text="Post detail" />
<TextBox
x:Name="DraftTitleTextBox"
Grid.Row="1"
PlaceholderText="Title"
Text="{Binding DraftTitle, Mode=TwoWay}" />
<postitControls:MarkdownEditorControl <AvaloniaEdit:TextEditor
Grid.Row="2" Grid.Row="2" VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
IsPreviewVisible="{Binding IsMarkdownViewerVisible, Mode=TwoWay}"
MarkdownText="{Binding DraftArticle, Mode=TwoWay}"
Document="{Binding DraftArticleDocument, Mode=TwoWay}" />
<Border x:Name="AttachmentsBorder" Document="{Binding DraftArticleDocument, Mode=TwoWay}"
Grid.Row="3" ShowLineNumbers="True"
Padding="8" FontFamily="Cascadia Code,Consolas,Menlo,Monospace"/>
BorderBrush="LightGray"
BorderThickness="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Text="Pièces jointes" />
<ItemsControl ItemsSource="{Binding DraftAttachments}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding FileName}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
</Grid> <Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="Pièces jointes" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding DraftAttachments}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding FileName}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<postitControls:StatusBar Grid.Row="4"
DataContext="{Binding ActionStatus}" />
</Grid>
</Border>
</Grid> </Grid>
</Grid> </StackPanel>
</ScrollViewer>
</ContentPage> </ContentPage>

View file

@ -1,9 +1,9 @@
using System;
using System.IO; using System.IO;
using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
namespace PostIt.Views.Blogs; namespace PostIt.Views.Blogs;
@ -50,6 +50,7 @@ public partial class BlogsPage : ContentPage
uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name))); uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
} }
vm.DraftAttachments.Clear();
foreach (var upload in uploads) foreach (var upload in uploads)
vm.DraftAttachments.Add(upload); vm.DraftAttachments.Add(upload);
} }

View file

@ -1,111 +0,0 @@
<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>

View file

@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace PostIt.Views.Chat;
public partial class ChatPage : ContentPage
{
public ChatPage()
{
InitializeComponent();
}
}

View file

@ -15,9 +15,6 @@ using PostIt.Services;
using PostIt.ViewModels.Commands; using PostIt.ViewModels.Commands;
namespace PostIt.Views.Commands; namespace PostIt.Views.Commands;
using Mapsui.Tiling; // Ensure you have this namespace
public partial class RdvPage : ContentPage public partial class RdvPage : ContentPage
{ {
@ -47,16 +44,6 @@ public partial class RdvPage : ContentPage
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService(); _reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
InitializeComponent(); InitializeComponent();
InitializeMap(); InitializeMap();
// ... inside your Map setup logic ...
string version = GetType().Assembly.GetName().Version?.ToString() ?? "1.1";
// 1. Create a unique user-agent string for your app (e.g., "AppName/Version (ContactEmail)")
string myCustomUserAgent = $"PostIt/{version} (paul@pschneider.fr)";
// 2. Pass your custom user agent into the OpenStreetMap helper
var osmLayer = OpenStreetMap.CreateTileLayer(myCustomUserAgent);
// 3. add your newly identified layer
_locationMap.Map.Layers.Add(osmLayer);
} }
private void InitializeComponent() private void InitializeComponent()

View file

@ -1,38 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.AboutPage"
x:DataType="vm:AboutPageViewModel"
Header="À propos">
<Design.DataContext>
<vm:AboutPageViewModel />
</Design.DataContext>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12"
Margin="16">
<TextBlock Text="PostIt"
FontSize="28"
FontWeight="Bold"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Version, StringFormat='Version {0}'}"
HorizontalAlignment="Center"
Opacity="0.8"/>
<TextBlock Text="{Binding Description}"
TextWrapping="Wrap"
TextAlignment="Center"
MaxWidth="420"/>
<TextBlock Text="{Binding License}"
TextWrapping="Wrap"
TextAlignment="Center"
MaxWidth="420"
Opacity="0.8"
FontStyle="Italic"/>
<Button Content="Dépôt du projet"
Command="{Binding OpenRepositoryCommand}"
HorizontalAlignment="Center"/>
<Button Content="Signaler un problème"
Command="{Binding OpenIssuesCommand}"
HorizontalAlignment="Center"/>
</StackPanel>
</ContentPage>

View file

@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class AboutPage : ContentPage
{
public AboutPage()
{
InitializeComponent();
}
}

View file

@ -1,59 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.AdministrationPage"
x:DataType="vm:AdministrationPageViewModel"
Header="Administration">
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="16" RowSpacing="12">
<TextBlock Grid.Row="0"
Text="Administration"
FontSize="22"
FontWeight="SemiBold"/>
<StackPanel Grid.Row="1" Spacing="8">
<TextBox Text="{Binding SearchText, Mode=TwoWay}"
PlaceholderText="Rechercher un utilisateur..."/>
<Button Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"/>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<TextBlock Text="{Binding StatusMessage}"
TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="3" ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
<ListBox ItemsSource="{Binding FilteredUsers}"
SelectedItem="{Binding SelectedUser, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Padding="8" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
<StackPanel Spacing="4">
<TextBlock Text="{Binding UserName, StringFormat='Nom : {0}'}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Column="1" Padding="12" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6" Background="#F6F8FA">
<StackPanel Spacing="8">
<TextBlock Text="Détails" FontWeight="SemiBold" FontSize="18"/>
<TextBlock Text="{Binding SelectedUser.UserName, StringFormat='Nom d’utilisateur : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.Address, StringFormat='Adresse : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.BankInfoSummary, StringFormat='Banque : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</ContentPage>

View file

@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class AdministrationPage : ContentPage
{
public AdministrationPage()
{
InitializeComponent();
}
}

View file

@ -1,55 +1,19 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:lang="using:Yavsc.Lang"
xmlns:messaging="using:Yavsc.Abstract.Models.Messaging"
x:Class="PostIt.Views.HomePage" x:Class="PostIt.Views.HomePage"
x:DataType="vm:HomePageViewModel" x:DataType="vm:HomePageViewModel"
Header="Home"> Header="Home">
<Design.DataContext> <Design.DataContext>
<vm:HomePageViewModel /> <vm:HomePageViewModel />
</Design.DataContext> </Design.DataContext>
<ScrollViewer HorizontalScrollBarVisibility="Disabled"> <StackPanel HorizontalAlignment="Center"
<StackPanel Margin="16" Spacing="12" MaxWidth="720" VerticalAlignment="Center"
HorizontalAlignment="Stretch"> Spacing="12">
<TextBlock Text="{x:Static lang:Resources.GreetingText}" <TextBlock Text="Welcome to the Home Page"
FontSize="22" FontSize="22"
FontWeight="SemiBold" FontWeight="SemiBold"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Notifications" FontSize="20" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Actualiser"
Command="{Binding RefreshNotificationsCommand}"
IsEnabled="{Binding !IsLoadingNotifications}"/>
</Grid>
<ProgressBar IsIndeterminate="True" Height="4"
IsVisible="{Binding IsLoadingNotifications}"/>
<TextBlock Text="{Binding NotificationsError}" TextWrapping="Wrap"
Foreground="{DynamicResource SystemControlErrorTextForegroundBrush}"/>
<TextBlock Text="Aucune notification pour le moment."
IsVisible="{Binding HasNoNotifications}" TextWrapping="Wrap"/>
<ItemsControl x:Name="NotificationsList" ItemsSource="{Binding Notifications}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="messaging:Notification">
<Border Padding="12" Margin="0,0,0,8" CornerRadius="6"
BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Spacing="6">
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
TextWrapping="Wrap"/>
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Les Salons de discussion"
Command="{Binding OpenChat}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Open Blog Interface" <Button Content="Open Blog Interface"
Command="{Binding OpenBlogs}" Command="{Binding OpenBlogs}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
@ -74,22 +38,5 @@
Command="{Binding OpenMyFiles}" Command="{Binding OpenMyFiles}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mon profil"
Command="{Binding OpenProfile}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mon activité / performer"
Command="{Binding OpenPerformerConfiguration}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Administration"
Command="{Binding OpenAdministration}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mon usage / administration"
Command="{Binding OpenResourceUsage}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel> </StackPanel>
</ScrollViewer>
</ContentPage> </ContentPage>

View file

@ -1,5 +1,4 @@
using Avalonia.Controls; using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Views; namespace PostIt.Views;
@ -9,12 +8,4 @@ public partial class HomePage : ContentPage
{ {
InitializeComponent(); InitializeComponent();
} }
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
if (!Design.IsDesignMode && DataContext is HomePageViewModel vm
&& vm.RefreshNotificationsCommand.CanExecute(null))
_ = vm.RefreshNotificationsAsync();
}
} }

Some files were not shown because too many files have changed in this diff Show more