Follow-up to the 0c693237 'sdk version bump' (net10.0 -> net11.0
across the whole solution) and the work to get PostIt.Android building
under the .NET 11 preview SDK installed locally (~/.dotnet):
- Drop Xamarin.AndroidX.Browser and Xamarin.AndroidX.Core.SplashScreen
from PostIt/Directory.Packages.props. They are not pulled by
Avalonia.Android 12.1.1 (its nuspec declares AppCompat + Window only),
are not referenced by any PostIt code, and we already removed the
SplashScreen XML attributes from styles.xml in commit 6b2867c8.
- PostIt.Android.csproj: bump SupportedOSPlatformVersion 23.0.0 ->
24.0.0. Required by the Microsoft.Android.Sdk.Linux 37.0.0-preview.7
target (Android API 24 is the new minimum under .NET 11); below that
the build emits NETSDK warnings about an EoL target SDK level.
Build status (PostIt.Android, Debug, android-x64, EmbedAssemblies):
0 errors, 15 warnings, ~4m24s cold. The locally-installed .NET 11
SDK 11.0.100-preview.7.26381.103 needs /opt/android-sdk/platforms
/android-37.0 to exist; Paul has set up a symlink to the
android-37.1 (Android 17 preview) install until Microsoft catches
up the SDK manifest.
PostIt.Tests: 49/62 passing after the migration; 13 new failures,
most of them HttpListener teardown noise around the OIDC stub
authority suite. Out of scope of this commit.
WIP: addressing the layer-3 WindowingPlatformStub.NotSupportedException
crash on PostIt.Android by downgrading to the Avalonia 11 / Avalonia.Maui
11 stack, where the Android runtime platform layer (IWindowingPlatform)
gets properly bound via the MAUI embedding extension instead of being
left as a stub.
Touched files:
- src/PostIt/Directory.Packages.props:
- Avalonia 12.1.1 -> 11.3.20 (Avalonia, Avalonia.Android,
Avalonia.AvaloniaEdit, Avalonia.Browser, Avalonia.Desktop,
Avalonia.Fonts.Inter, Avalonia.Themes.Fluent).
- New: Avalonia.Maui 11.3.0.
- Microsoft.Maui.Essentials 10.0.100 -> 8.0.100 (aligns with the
Microsoft.Maui.Controls 8.0.x family that Avalonia.Maui 11.3.0
pulls transitively).
- New: Microsoft.Maui.Controls 8.0.100.
- src/PostIt/PostIt.Android/PostIt.Android.csproj:
- TargetFramework net10.0-android -> net9.0-android36.0 (Avalonia
Android 11.3.20 targets net8.0-android34.0, but the
compat-ascending fallback lets us consume from net9.0-android36.0
against the API-36 platform that's already installed locally).
- Add Avalonia.Maui PackageReference.
- Restore Microsoft.Maui.Essentials PackageReference (left in
earlier removal during the MAUI-cause-of-crash hypothesis check).
- src/PostIt/PostIt/PostIt.csproj: TargetFramework net10.0 -> net9.0
(must match what PostIt.Android consumes transitively, otherwise
the build chain breaks).
- src/Yavsc.Abstract/Yavsc.Abstract.csproj: net10.0 -> net9.0 (same
reason; six other consumers stay on net10.0 and pick up the new
assembly via standard TFM compat).
- src/Yavsc.Api.Client/Yavsc.Api.Client.csproj: net10.0 -> net9.0
(same reason).
- src/PostIt/PostIt.Android/Application.cs: rewritten as a bare
Android.App.Application. Avalonia 11 no longer ships
AvaloniaAndroidApplication<TApp> (that was an Avalonia 12
introduction); the Avalonia init now lives in MainActivity via
AvaloniaMainActivity<TApp>. The class is kept only so the
[Application] manifest entry remains.
- src/PostIt/PostIt.Android/MainActivity.cs: now inherits
AvaloniaMainActivity<App> and overrides CustomizeAppBuilder to
chain .UseMaui<PostIt.Maui.MauiEmbeddingApp>(this) before
.WithInterFont(). AvaloniaMainActivity<TApp> handles the
AvaloniaView initialisation in OnCreate.
- src/PostIt/PostIt/Maui/MauiEmbeddingApp.cs (new): empty
Microsoft.Maui.Controls.Application used as the embedding host for
Avalonia.Maui. The MAUI window it produces is consumed by the
Avalonia.Maui embedding pipeline and never surfaces to the user;
Avalonia owns the actual visual tree.
Build status: 11 errors remaining, all CS0234 / CS0246 against
'Avalonia.Controls.ContentPage'. Avalonia 11 did not ship
ContentPage (it's an Avalonia 12 type, presumably for MAUI parity).
All seven PostIt pages inherit ContentPage today and need to be
reworked to UserControl before the build can complete. Follow-up
commit will do that.
Reference: AGENTS.md, 'PostIt.Android boot crash sur AVD x86_64 :
investigation par couches', couche 3.
- PostIt.Android.csproj: drop Xamarin.AndroidX.Core.SplashScreen and
Xamarin.AndroidX.Browser. Neither is pulled transitively by
Avalonia.Android 12.1.1 (its nuspec declares only AppCompat +
Window), and they are not referenced anywhere in PostIt code.
- Resources/values-v31/styles.xml: drop the windowSplashScreen*
items and the 'postSplashScreenTheme' reference, which require
the dropped SplashScreen package. We fall back to the system
splash screen on Android 12+ devices; a custom one can come
back when we add it on purpose.
Two adjustments after the Android boot investigation
(cf. AGENTS.md section 'PostIt.Android boot crash sur AVD
x86_64 : investigation par couches'):
- Avalonia 12.0.4 -> 12.1.1 in PostIt/Directory.Packages.props.
12.1.x is the current Avalonia 12 stable line; 12.0.4 had a
known WindowingPlatformStub regression on Pixel x86_64 AVDs
(issue AvaloniaUI/Avalonia#18459 family). 12.1.1 does not
actually fix the layer-3 NotSupportedException on this
specific Pixel + Android 16 + x86_64 combination (verified
by rebuild + logcat capture on 2026-08-22), but staying on
12.0.4 forever is wrong, and 12.1.1 is the baseline most
users are on. The Makefile qemu-build already passes
-p:EmbedAssembliesIntoApk=true and -p:RuntimeIdentifier=
android-x64 to work around the Fast Deployment / AOT issues.
- qemu-logcat-boot LOGCAT_BOOT_WAIT 15s -> 30s. The crash
reproduces well under 5s, so the wait value is moot for
diagnosis, but 30s gives a wider margin when the runtime
happens to be slow to JIT (boot tries to draw a frame in
texture-pass scenarios). Override on the command line is
unchanged: 'make qemu-logcat-boot LOGCAT_BOOT_WAIT=N'.
No MAUI change: Microsoft.Maui.Essentials was deliberately
unhooked during a hypothesis check and put back to keep the
contact-flow code path stable. The MAUI removal made no
difference to the crash (verified).
Bundled end-of-branch commit on feat/postit-acl-members.
PostIt UI for circles + per-post ACL
- Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders
(Bearer/OIDC scope tests vs. blog API fakes live where they
belong) and introduces PostItHeadlessCollection so the
Avalonia.Headless tests share a single xUnit collection
instead of contending with the EF-Core test host.
- Adds BlogAclApiTests (a brand-new behavioural layer over
POST /api/v1/blogacl) and the fakes it relies on
(BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember
DialogTests); pulls UserId-through-OIDC-sub path into
BearerScopeTests / FakeAuthorizingBrowser /
OidcStubAuthority.
- App.axaml.cs gets a small PushPageAsync touch-up the new
tests rely on.
- Drops UnitTest1.cs (xUnit scaffold, never used).
Yavsc.Blogs.Tests — SQLite instead of InMemory
- Bumps Yavsc.Blogs.Tests.csproj on
Microsoft.EntityFrameworkCore.Sqlite and rewrites
BlogsWebServerFixture to hold a single shared
SqliteConnection (Cache=Shared) for the fixture lifetime,
with a sync Dispose close to dodge async teardown hangs.
Reason: the EF Core InMemory provider silently ignores FKs,
which masked the kind of bug we are about to pin in the
ACL tests. SQLite enforces them, so any future INSERT that
forgets to seed its parent rows fails loudly here instead
of passing the test and breaking prod.
- PublishEndpointTests and BlogApiSmokeTests get a one-line
tweak to follow the new connection lifecycle.
Foreign-key fallout: seed the default user in the fixture
- Adds BlogsWebServerFixture.SeedUser(userName). Now that
SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every
test that POST/PUT/DELETE a BlogPost and sends AuthorId=
'tester' in the payload needs an AspNetUsers row to satisfy
the FK or it returns 500 with SQLite Error 19.
- BlogApiTests wraps the existing ResetDatabase with a
ResetAndSeedDefaultUser helper for the six mutating tests;
the four GET-only and ModelState-only tests keep the bare
ResetDatabase.
- Side benefit: every test in Yavsc.Blogs.Tests now finishes
cleanly instead of hanging at teardown — previously a stuck
test held the shared SqliteConnection open and the next
tests waited indefinitely.
Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25
green from a clean run, no fixture teardown hang.
- CirclesPage: drop VisualRoot/MainWindow hack, switch to App.PushPageAsync(vm)
- CirclesPageViewModel: make OpenAddMemberAsync public so Avalonia XAML trampoline can call it
- AddCircleMemberDialog: bind SearchAsync/Add (drop Command suffix)
- UserSearchApiController: route under Constants.APIPrefix (= api/v1/user-search), matching the rest of Yavsc.Blogs controllers and the PostIt client's BlogsApiUrl default
The two recent commits (3fbbafc4, 0065de70) replaced the
OpenSettingsRequested event + CurrentViewModel assignment with
App.PushPageAsync(vm): the VM resolves the target ViewModel
through DI, App resolves the Control through the ViewLocator,
guards against double-push, and pushes via NavRoot. The docs
were still describing the pre-refactor world.
Update three places:
- CONTRIBUTING.md — the "Navigation (PostIt)" rule now
describes App.PushPageAsync as the single channel and shows
the canonical OpenSettings command as the example.
- doc/architecture/postit.md — the Navigation section
distinguishes VM-first navigation (App.PushPageAsync) from
lifecycle signals (LoginSucceeded, LogoutCompleted) and
drops the obsolete OpenSettingsRequested row.
- src/PostIt/PostIt/App.axaml.cs — refresh the SettingsPage
singleton justification: point (c) now describes the
anti-empilement guard inside PushPageAsync, not the
OpenSettingsRequested handler that no longer exists.
No production behaviour change — doc only (and the inline
comment that referenced a removed event).
Three buttons on MainPage's toolbar are reported as inoperative
in the running app: ACL, Mes cercles, and [DEV] Signature. They
click but no dialog / page opens.
This commit adds headless UI tests that drive each button via
the Avalonia headless harness (KeyPressQwerty(Enter) on a
focused, x:Name'd button, per the CalculatorTests pattern in
Avalonia.Samples) and asserts the post-click top of
NavRoot.NavigationStack is a non-null Page.
The tests fail today on every button (stack size before == after
== 1): the click does not push anything. The bug is the user's
real complaint — the test is now wired to catch it.
To make the buttons reachable by the harness without walking
the visual tree (which does not see buttons hosted inside a
NavigationPage), name the two unnamed buttons:
- ACL -> ManageAclButton
- Mes cercles -> OpenCirclesButton
([DEV] Signature was already named OpenSignatureDevButton.)
The XAML change is cosmetic; bindings and commands are
untouched. The test pattern follows SessionStatusBannerTests:
new MainWindow().Show(), PushAsync(MainPage), drive controls via
their generated x:Name fields.
Replaces the previous 'Visibility enum' approach (commit 33ecfa7e,
reverted in 42625f5d) with the existing BlogSpotPublication
mechanism. Paul pointed out that the system already had a
publication table and a Publish field on BlogPostEditViewModel;
we just didn't expose it through the API.
The toggle is its own action on the API surface — a dedicated
endpoint rather than a field on the existing BlogPost wire
DTO. This keeps the BlogPostDto contract unchanged and avoids
shoe-horning 'Publish' into the entity model alongside
Title/Article (where the existing BlogSpotService.Modify
already takes two overloads and a third felt like drift).
Server (Yavsc.Blogs / Yavsc.Server)
- PUT /api/BlogApi/{id}/publish body { publish: bool }
Returns 204 on success, 404 when the post doesn't exist,
Challenge() (401) when the caller is not the author
(EditPermission gate). Idempotent: PUT because the
resulting state matches the body, not the request.
- BlogSpotService.SetPublishAsync(user, postId, publish)
factored out of the existing
Modify(BlogPostEditViewModel) inline toggle, so the new
endpoint reuses the same BlogSpotPublication row logic
(add row if missing on publish=true, remove row if
present on publish=false).
- BlogPost.IsPublished (NotMapped) is now hydrated by the
service after each Index/Details fetch — a single bulk
lookup, not N+1 — and surfaces through the wire JSON
so PostIt can show the current state without a follow-up
request.
- ApplicationUser nav properties (Posts, Book,
DeviceDeclaration, Connections, Circles, BlackList,
Rooms, RoomAccess, Membership, BlogComments) now carry
BOTH [JsonIgnore] (Newtonsoft) and
[System.Text.Json.Serialization.JsonIgnore] so the
Yavsc.Blogs test fixture (System.Text.Json) stops
exploding on object cycles when serialising
BlogPost.Author.Posts.Author.Posts. Production
(Yavsc.Org, NewtonsoftJson) was already safe via the
Newtonsoft-only attribute; this commit just makes the
Yavsc.Blogs side consistent.
Client (Yavsc.Api.Client)
- BlogApiClient.SetPublishAsync(id, publish) → PUT to the
new endpoint.
DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- BlogPostDto.IsPublished added. Same shape as the entity
field; serialised as a plain bool in JSON.
UI (PostIt)
- MainPageViewModel.DraftIsPublished (ObservableProperty)
mirrors the existing DraftTitle/DraftArticle pattern;
hydrated from SelectedPost.IsPublished on selection
change. TogglePublish command pushes the new state to
SetPublishAsync and updates both the buffer and the
selected post locally so the UI reflects the change
without a full Refresh.
- MainPage.axaml: a CheckBox 'Publié' in the toolbar,
bound to DraftIsPublished TwoWay and wired to
TogglePublishCommand. The toggle is its own action
(not part of Save), matching the wire contract.
Tests (Yavsc.Blogs.Tests)
- PublishEndpointTests (4 [Fact]):
* PUT publish=true returns 204 and IsPublished is true
in the next GET
* PUT publish=false clears IsPublished
* PUT on an unknown post returns 404
* PUT by a non-author does not return 204 (Challenge)
- BlogsWebServerFixture now wires
app.UseDeveloperExceptionPage() so 500s in tests
surface a real stack trace instead of an empty
InternalServerError body — much easier to diagnose
future regressions.
Test totals: 24/24 Yavsc.Blogs.Tests (was 20, +4
PublishEndpoint), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Publié' label is localised; the
rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ IsPublished reconciliation
in the admin web Yavsc (the Org UI already edits Publish
inline; no work needed there).
Replace the implicit 'ACL empty = private' convention with an
explicit two-axis model: Visibility is the master switch, the
ACL is the exception list.
Semantics (matches what BlogSpotService.Index / Details enforce):
Visibility.Public + empty ACL : every caller sees it
Visibility.Public + non-empty : only author + ACL circles + admin
Visibility.Private + any ACL : only author + admin (ACL ignored)
ACL is preserved across
Private/Public flips so
reopening is lossless
The Public+non-empty shape is the 'restrict by exception' case:
open by default, narrowed by the ACL. This is intentionally
different from the previous behaviour, where a Public post
with a non-empty ACL was effectively ACL-restricted anyway —
the new model makes that explicit and removes ambiguity.
Server (Yavsc.Blogs / Yavsc.Server)
- New enum Visibility { Private, Public } in
Yavsc.Abstract.Blogspot (so the wire DTO and the EF entity
share the same type). Stored as int via .HasConversion<int>()
on BlogPost.Visibility. Default Private on construction;
the column default in the migration is 0 so existing rows
land Private without any data migration.
- BlogSpotService.Index: filter rewritten to honour the two-
axis model. Authenticated and anonymous callers now share
the same shape (Public+emptyACL visible to all, otherwise
scoped). Admin reads still go through PermissionHandler.
- PermissionHandler.IsPublic: dropped the blogSpotPublications
lookup, replaced with the Visibility + empty-ACL check that
matches the new model. PermissionHandler.IsSponsor and
IsOwner unchanged.
- UserHelpers.UserPosts (the per-author feed for
/CircleMembers/Details and similar): mirror of the
Index filter, so the two code paths can't silently diverge.
- BlogPostEditViewModel.Publish untouched on this commit. It
still controls whether a row exists in BlogSpotPublication;
the two systems coexist (Publish = 'is this draft published',
Visibility = 'who can read it'). Follow-up to consolidate.
EF migration (Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility)
- Scaffolded by 'dotnet ef migrations add', not hand-edited,
per the repo preference for generated migrations.
- Adds the new Visibility column (int, NOT NULL, default 0).
- Also drops three shadow-state ClientId1 foreign keys and
their indexes/columns on ClientScopes, ClientRedirectUris,
ClientGrantTypes. These shadow FKs were created by EF from
HasOne<Client>().HasForeignKey(e => e.ClientId) mappings
that have long since been removed from
ApplicationDbContext.OnModelCreating, but the snapshot was
never regenerated against the current model. The columns
are nullable ints with no production data, so the drop is
lossless. Without this, EF Core would keep emitting
warnings on every migration add and the model would drift
further from reality.
DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- Visibility property added to BlogPostDto. System.Text.Json
serialises the enum as its underlying int, so the JSON
shape is a plain number, no JsonConverter needed.
Client UI (PostIt)
- MainPageViewModel: DraftVisibility ObservableProperty
mirroring the existing DraftTitle/DraftArticle pattern.
Initialised to Private so a fresh draft is private by
default. Save command writes the chosen value into the
BlogPostDto payload for both CreatePostAsync and
UpdatePostAsync. OnSelectedPostChanged hydrates the buffer
from the server-supplied value.
- AllVisibilities property on the VM exposes [Private, Public]
in that order, bound by the ComboBox in MainPage.axaml.
- VisibilityLabelConverter (PostIt.Views) maps the enum to
French user-facing labels ('Privé' / 'Public'); registered
in App.axaml as a static resource.
- MainPage.axaml: a new ComboBox row in the editor pane
between Title and Article. Uses the existing 'no hardcoded
Background without Foreground' lesson so dark mode works.
Tests (Yavsc.Blogs.Tests)
- BlogVisibilityTests (5 [Fact]): drive GET /api/v1/blog with
Visibility fixtures seeded directly in the in-memory DB:
* Private + ACL: only the author sees it
* Public + empty ACL: any authenticated caller sees it
* Public + non-empty ACL: caller without ACL membership
does NOT see it
* Private + ACL: ACL is ignored, only the author sees it
* Visibility round-trips through the JSON wire (int 1)
- UserHelpersVisibilityTests (4 [Fact]): exercise the helper
directly so the two code paths (Index filter vs per-author
feed) can't diverge silently. Same fixture, no HTTP.
Test totals: 29/29 Yavsc.Blogs.Tests (was 20, +5 BlogVisibility
+4 UserHelpersVisibility), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Visibilité :' label is localised; the
rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ Visibility consolidation
(which system wins when both are set on the same post?).
- Org-side UI for editing Visibility (the admin web Yavsc
still edits posts without a visibility field).
Round-trip out a long-standing hole in the ACL feature: until
this commit a circle on Yavsc was an empty named bucket. You
could create 'Famille' and grant it on a post, but the circle
carried no members, so 'Famille' authorised no one. The MVC
admin controller (Yavsc.Org.Controllers.CircleMembersController)
existed but had no REST counterpart, so PostIt — which only
talks to the Yavsc.Blogs API — had no way to manage membership
at all.
This commit closes that gap end-to-end:
Server (Yavsc.Blogs)
- GET /api/circle/{id}/members list members
- POST /api/circle/{id}/members add a user (body { userId })
- DELETE /api/circle/{id}/members/{userId} remove a user
All three are scoped to caller == circle.OwnerId; non-owned
circles return 404 (not 403) to avoid leaking existence, in
line with the rest of the controller.
- Two new DTOs (CircleMemberDto, AddCircleMemberDto) for the
wire shapes. CircleMemberDto mirrors UserSearchResultDto
minus Email — membership UI doesn't need contact details.
Tests (Yavsc.Blogs.Tests)
- CircleMembersApiTests: 5 [Fact] covering empty list,
add+get, duplicate add → 409, remove, and cross-owner 404.
Test users (alice, bob) are seeded directly through the
in-memory DbContext — the Blogs fixture doesn't stand up
UserManager<ApplicationUser>.
Client (Yavsc.Api.Client)
- CircleMemberDto + 3 methods on CircleApiClient:
GetMembersAsync, AddMemberAsync, RemoveMemberAsync.
All match the server's contract: 404 flattens to null,
409 surfaces as an exception (callers can dedupe beforehand
if they want idempotent behaviour).
UI (PostIt)
- CirclesPageViewModel gains a Members ObservableCollection
that auto-loads on SelectedCircle change (via the partial
setter generated by [ObservableProperty]). Commands:
LoadMembersAsync, OpenAddMember (raises an event the view
subscribes to), OnAddMemberConfirmedAsync (called by the
view when the dialog confirms a selection), RemoveMemberAsync.
409 (already a member) is detected from the exception
message and surfaced as a friendly status rather than an
error — a likely race when the same user gets added twice
through two UI paths.
- CirclesPage layout is now two-pane (circles + editor on the
left, members of the selected circle on the right). The
member pane has an 'Ajouter un membre' button that opens
AddCircleMemberDialog. Code-behind wires the dialog's
Confirmed event back into the VM via an async lambda
wrapper (EventHandler<T> wants void, the VM method is
async Task).
- AddCircleMemberDialog is a ContentPage (light modal, same
pattern as PostAclDialog). Its ViewModel consumes
IUserDirectory — the abstraction introduced by 04a31709
to fix the 'user search should not be IContactService'
confusion. The dialog raises Confirmed with the picked
UserSummary; the host (CirclesPage) is responsible for
calling CircleApiClient.AddMemberAsync.
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: all visible text still hard-coded French.
- XAML accessibility audit of pre-existing pages.
- Avalonia.Headless UI tests of the new navigation flow.
Tests: 51/51 PostIt.Tests green, 20/20 Yavsc.Blogs.Tests
green (was 15; +5 for CircleMembersApiTests), 44/44
Yavsc.Org.Tests green (no regression).
IContactService used to be the catch-all for "people you can reach
from PostIt": on mobile it read the device-local address book, on
desktop it queried the central /api/user-search endpoint and merged
both worlds into a single ContactDto (a flat Email field, an
ObservableCollection cache, a SearchAsync method). Two unrelated
flows under the same name, with a wire shape (Email) silently
flattening the mobile provider's multi-email list.
Split into two interfaces, each with a single responsibility:
- IContactService: device-local address book only. Mobile provider
reads MAUI Essentials Contacts.Default and carries the full email
list per contact. Desktop provider is an honest stub returning an
empty list — the desktop has no local address book, and inviting
external people from desktop is a separate flow (manual email
entry + invitation endpoint) that doesn't belong here.
- IUserDirectory: central Yavsc user directory, the only consumer
of /api/user-search. Both Desktop and Mobile providers delegate
to UserSearchClient; the platform split exists so future
platform-specific sources (offline cache, directory-scoped
providers) can plug in without disturbing consumers.
ContactDto restores IReadOnlyList<string> Emails (the flat Email
from d0e0f4c1 was a regression that matched the wire shape of
/api/user-search at the cost of the mobile provider's per-contact
list). UserSummary is a separate platform-neutral record that
mirrors the server's UserSearchResultDto without leaking transport
concerns.
App.axaml.cs registers both interfaces as singletons.
Build + 51/51 PostIt.Tests green. No UI consumer yet — these
interfaces are still plomberie; the ViewModel that joins them for
the "add to a circle" / "invite someone" flows is a follow-up.
Replaces the empty ContactService.Desktop stub with a real
implementation backed by UserSearchClient. Closes the loop
between the server-side /api/user-search endpoint (b3056f1c),
the client wrapper (6e7e0414), and the platform abstraction.
IContactService gains:
- SearchAsync(string query, CancellationToken): on desktop,
hits /api/user-search and appends results to an in-memory
cache. On mobile, throws PlatformNotSupportedException —
mobile providers use the device-local address book
(GetDeviceContactsAsync) and don't talk to a network search.
- Contacts (ObservableCollection<ContactDto>): live view of
the cache; UI binds directly to it. Mobile populates it
inside GetDeviceContactsAsync (eager load); desktop populates
it via SearchAsync (lazy, on-demand).
ContactDto shape changes:
- Emails (IReadOnlyList<string>) -> Email (string?). The
/api/user-search endpoint returns one email per user. The
use case ('invite / add to a circle') only needs one.
- Mobile provider flattens its per-contact email list down
to the first non-empty entry (a small functional loss that
matches the wire shape).
App.axaml.cs constructs a ContactService from the
UserSearchClient singleton and registers it as
IContactService so future ViewModels can take the interface
by constructor injection.
Build + 51/51 tests green. The mobile provider is still
gated by #if ANDROID || IOS and not exercised by the
Desktop test target — runtime behaviour on Android will
need a smoke test on device when PostIt.Android lands.
Adds the client-side half of the user-search endpoint landed
on the server in b3056f1c (commit 6 on this branch). The
client mirrors the server's filter contract:
- query: substring match on FullName or UserName
- email: exact match on Email
- take: 1..100, default 25
Empty (query + email) short-circuits to an empty list
client-side rather than letting the server return the first
`take` users alphabetically — the address-book UX is
"type to search", not "show me a directory".
The DTO (Yavsc.Api.Client.Dtos.UserSearchResultDto) is a flat
shape (Id, UserName, FullName, Avatar, Email) with no
navigation properties; field names match the JSON the server
emits so deserialisation is a no-op.
PostIt wiring:
- App.axaml.cs constructs a UserSearchClient singleton and
registers it alongside CircleApiClient and BlogAclApiClient.
- The PostIt.csproj ProjectReference to Yavsc.Api.Client was
in place before this commit on feat/postit-acl; the rebase
of feat/app-invite on top of feat/postit-acl dropped it.
This commit re-adds it.
Splits the single ContactService class (which threw
PlatformNotSupportedException on non-Android/iOS targets) into a
platform-conditional structure:
- IContactService + ContactDto: shared abstraction in
src/PostIt/PostIt/Services/IContactService.cs. ViewModels depend
on this; concrete providers map their native shapes to ContactDto.
- ContactService.Mobile.cs: MAUI Essentials implementation, compiled
only when ANDROID or IOS is defined. Wraps
Contacts.Default.GetAllAsync() with permission handling and a
NotImplementedInReferenceAssemblyException safety net.
- ContactService.Desktop.cs: stub returning an empty list, compiled
when neither ANDROID nor IOS is defined. Replaces the
'throw PlatformNotSupportedException' path so desktop targets
(PostIt.Desktop, PostIt.Browser) build and run cleanly.
The Microsoft.Maui.Essentials portable facade is referenced from
PostIt.csproj, but it only becomes functional when the host
application project (PostIt.Android, future PostIt.iOS) also
references the platform-specific implementation.
No tests added: per AGENTS.md, a 'stub returns empty list' test on
PostIt.Tests (net10.0 desktop target) would be cosmetic and not
detect the real failure mode. Android-side tests require a working
PostIt.Android project, which doesn't exist yet.
Future providers (Google Contacts API, Exchange, CardDAV) plug in
as additional IContactService implementations selected by DI
configuration.
Adds Microsoft.Maui.Essentials package and <UseMaui>true</UseMaui> to
src/PostIt/PostIt/PostIt.csproj so the shared project can compile code
that calls MAUI Essentials APIs (Microsoft.Maui.ApplicationModel.*).
Also adds a draft ContactService that wraps Contacts.Default.GetAllAsync()
behind a runtime platform check and permission request.
WIP caveats:
- The portable MAUI Essentials facade compiles on net10.0 but throws
NotImplementedInReferenceAssemblyException at runtime when no
platform-specific MAUI Essentials binary is loaded. A PostIt.Android
project (or equivalent) must reference the Android MAUI Essentials
implementation for Contacts.Default.GetAllAsync() to actually work.
- On desktop (Linux/macOS/Windows) the API is unsupported by design;
ContactService currently throws PlatformNotSupportedException. A
desktop stub returning Array.Empty<Contact>() is the likely next step.
- No tests yet. The scaffold is unverified at runtime; build passes.
When commit 0e95e283 moved BlogPost from PostIt.Models to
Yavsc.Blogspot, it created an unfortunate collision with the
server-side EF entity Yavsc.Models.Blog.BlogPost. The two
classes have nothing in common beyond the name; the DTO is
the wire shape PostIt exchanges with the Blogs API, the EF
entity is the persistence model. Server code that imports both
namespaces (BlogSpotService.cs, etc.) ended up with 'BlogPost
is an ambiguous reference between X and Y' errors.
Renaming the client DTO to BlogPostDto (matching the
naming convention of the other DTOs in Yavsc.Api.Client.Dtos
— CircleDto, CircleAuthorizationDto, UserSearchResultDto)
disambiguates without renaming the EF entity on the server.
The namespace stays Yavsc.Blogspot; only the class name
changes. All call sites (client code, tests, XAML DataTemplates,
XML doc comments) are updated mechanically.
Landing the user-facing surface for the BlogAcl work. The user
can now:
1. Open the 'Mes cercles' page (a new 'Mes cercles' button on
the main page) and create / edit / delete their own
circles. The page lists circles in an ObservableCollection
bound to a ListBox; per-row buttons drive StartEdit and
Delete; the bottom editor pushes new / edited circles via
the Save command.
2. With a post selected, click the new 'ACL' button to open a
modal 'PostAclDialog' for that post. The modal shows the
current ACL entries (filtered server-side by Allowed.OwnerId
== caller) and a dropdown of the caller's circles to add.
Each entry has a 'Revoke' button.
Both pages follow the same pattern:
- ViewModel uses [ObservableProperty] for state and
[RelayCommand] for verbs; IsBusy drives a ProgressBar
overlay; StatusMessage surfaces server feedback.
- View follows the XAML-Background/Foreground lesson (no
hard-coded colours), so dark mode works without
contrast surprises.
- Code-behind is minimal — just AvaloniaXamlLoader.Load —
because navigation is driven by RelayCommand + event
(ManageAclRequested, OpenCirclesRequested) that the
MainPage code-behind handles via its DataContextChanged
handler.
The 'complete' scope (c) of this commit was confirmed by
Paul. Three follow-up tracks are deliberately out of scope
and tracked in MEMORY.md (2026-08-18):
- i18n: no .resx / IStringLocalizer today; all visible text
is hard-coded French.
- Avalonia.Headless UI tests: only ViewModel-level coverage
is feasible today; full navigation tests are a separate
effort.
- XAML accessibility audit of pre-existing pages (Settings,
MainPage) that predate the Background/Foreground lesson.
Build + 51/51 tests green.
App.axaml.cs is the composition root for PostIt. It now also
builds and registers:
- CircleApiClient (singleton) — backed by the same YavscApiClient
and the same blogs base URL as BlogApiClient
- BlogAclApiClient (singleton) — same shape
- IYavscApiClient -> YavscApiClient mapping (singleton). The
concrete class is still resolvable as YavscApiClient; the new
registration makes the same instance available as
IYavscApiClient so future consumers (and unit tests) can take
the interface without coupling to the concrete type.
The 3 high-level clients are singletons: they hold no mutable
state of their own, just a reference to YavscApiClient and a
base URL. Reusing the same instance across requests is what the
HttpClient inside YavscApiClient was already designed for.
Creates the high-level HTTP client library the PostIt UI will
consume to manage blog posts, circles, and per-post ACLs.
Clients in this commit:
- BlogApiClient (moved from PostIt/Services; same public surface,
now depends on IYavscApiClient instead of the concrete class).
- CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes
the blogs base URL explicitly in its constructor so it doesn't
need to know about PostIt's Settings type.
- BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl.
Same conventions as CircleApiClient.
DTOs (Yavsc.Api.Client.Dtos):
- CircleDto: id, name, ownerId, public. Stops short of the
navigation properties on the server-side Circle (Owner,
Members), which depend on ApplicationUser and other server
types we don't want to drag into the client.
- CircleAuthorizationDto: circleId, blogPostId, comment. Same
reason: the server entity has Target and Allowed navigation
properties the client never needs.
The clients now require the caller to pass the blogs base URL
explicitly in the constructor (previously the BlogApiClient
sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that
field is PostIt-specific). The one production call site
(App.axaml.cs) and four test call sites are updated to pass
the URL.
Build + 51/51 tests green. The IYavscApiClient abstraction was
landed in the previous commit so this one could be a pure
addition + relocation.
Yavsc.Api.Client is the new home for high-level HTTP clients
(BlogApiClient, CircleApiClient, BlogAclApiClient, etc.). It
depends on the host application's transport layer, but the host
(PostIt) is a UI app with OIDC, settings, and an ApplicationData
directory — none of which the abstract client library should
know about.
The IYavscApiClient interface captures just the transport
surface those clients need:
- HttpClient (so the client can configure BaseAddress)
- CallAsync<T> and CallAsync (the JSON over HTTP verb)
It deliberately leaves out LoginAsync / TrySilentLoginAsync /
CurrentAccessToken / HasValidSession / Settings — those are
authentication and configuration concerns, not transport. They
stay on the concrete YavscApiClient in PostIt.Services.
The concrete YavscApiClient now implements IYavscApiClient; the
existing public surface is unchanged (no breaking changes for
existing call sites in PostIt or the tests).
This commit only lays the foundation. The actual high-level
clients (Blog/Circle/BlogAcl) land in a follow-up commit that
re-uses this interface, so this one stays a small, reviewable
refactor.
BlogPost is shared between the server (Yavsc.Server/Models/Blog/
BlogPost.cs is the EF entity) and any client that talks to the
blogs API. Keeping the client-side DTO in PostIt.Models made
sense when there was only one consumer; now that the
Yavsc.Api.Client project is about to host BlogApiClient alongside
CircleApiClient and BlogAclApiClient, the DTO has to live in a
layer both the client project and PostIt can reference without
inverting the dependency.
Yavsc.Abstract is the existing home for cross-tier interfaces
and DTOs (IBlogPost, IBlogPostPayLoad, IApplicationUser).
Yavsc.Blogspot is the sub-namespace already used by the
matching interface, so the new concrete class follows.
Why not move Circle and CircleAuthorizationToBlogPost at the
same time? Both depend on the concrete ApplicationUser class
(via the Owner and Target/Allowed navigation properties) which
lives in Yavsc.Server. Moving them would mean either dragging
ApplicationUser into the abstract layer (huge blast radius —
auth, billing, chat, etc.) or weakening the navigation
properties (breaks EF Core shaping). They're staying where
they are; the new Yavsc.Api.Client will get DTO counterparts
instead.
Updated call sites:
- 4 .cs files: replace 'using PostIt.Models;' with
'using Yavsc.Blogspot;' where the file was actually using
BlogPost. Files that only used SignaturePadData keep their
'using PostIt.Models;' — that type stays put.
- 1 .axaml file: xmlns:models="using:PostIt.Models" ->
xmlns:models="using:Yavsc.Blogspot" (one DataTemplate for
the post list in MainPage).
Build + tests green (51/51).