diff --git a/README.md b/README.md
index d1ed912a..9747c9af 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,12 @@
C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé.
+# Statut actuel des actions Forgejo
+
+
+
+
+
# Statut actuel des actions GitHub
* [](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml)
diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props
index 900f1428..62b3a343 100644
--- a/src/PostIt/Directory.Packages.props
+++ b/src/PostIt/Directory.Packages.props
@@ -14,6 +14,7 @@
+
diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs
index e59e0d33..6f93edf9 100644
--- a/src/PostIt/PostIt/App.axaml.cs
+++ b/src/PostIt/PostIt/App.axaml.cs
@@ -59,6 +59,9 @@ public partial class App : Application
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
+ var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
+ var contactService = new ContactService();
+ var userDirectory = new UserDirectory(userSearchClient);
var services = new ServiceCollection();
@@ -87,6 +90,9 @@ public partial class App : Application
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
+ services.AddSingleton(userSearchClient);
+ services.AddSingleton(contactService);
+ services.AddSingleton(userDirectory);
services.AddTransient();
services.AddTransient();
services.AddTransient();
diff --git a/src/PostIt/PostIt/Services/ContactService.Desktop.cs b/src/PostIt/PostIt/Services/ContactService.Desktop.cs
new file mode 100644
index 00000000..fa7d37f6
--- /dev/null
+++ b/src/PostIt/PostIt/Services/ContactService.Desktop.cs
@@ -0,0 +1,36 @@
+#if !ANDROID && !IOS
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PostIt.Services;
+
+///
+/// Desktop stub for .
+///
+/// The desktop has no equivalent of the mobile address
+/// book (no Contacts.Default, no CardDAV out of the
+/// box). Rather than synthesise a list from a different
+/// source, this provider returns an empty list and lets the
+/// UI render an honest "no local contacts on this platform"
+/// message.
+///
+/// If desktop users want to invite people who aren't
+/// Yavsc members, that flow goes through a separate path
+/// (manual email entry + invitation endpoint) — not through
+/// . Finding existing Yavsc
+/// members is 's job, not this
+/// one's.
+///
+/// Future CardDAV / Google Contacts / Exchange
+/// providers can plug in here as additional
+/// implementations selected
+/// from DI by configuration.
+///
+public sealed class ContactService : IContactService
+{
+ public Task> GetDeviceContactsAsync(CancellationToken ct = default)
+ => Task.FromResult>(Array.Empty());
+}
+#endif
diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs
new file mode 100644
index 00000000..8dbd134d
--- /dev/null
+++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs
@@ -0,0 +1,82 @@
+#if ANDROID || IOS
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Maui.ApplicationModel.Communication;
+using Microsoft.Maui.ApplicationModel;
+using Microsoft.Maui.Devices;
+
+namespace PostIt.Services;
+
+///
+/// Mobile implementation backed by MAUI Essentials
+/// Contacts.Default.
+///
+/// Compiled only for ANDROID and IOS. On desktop targets,
+/// see ContactService.Desktop.cs (the stub that wins at
+/// compile time).
+///
+/// Note: at runtime, this class throws
+/// NotImplementedInReferenceAssemblyException unless
+/// the host application project also references the
+/// platform-specific Microsoft.Maui.Essentials implementation
+/// (typically PostIt.Android). On iOS the same is
+/// required via PostIt.iOS. On desktop the stub is used
+/// and this file is excluded.
+///
+public sealed class ContactService : IContactService
+{
+ public async Task> GetDeviceContactsAsync(CancellationToken ct = default)
+ {
+ if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
+ return Array.Empty();
+
+ try
+ {
+ var status = await Permissions.RequestAsync();
+ if (status != PermissionStatus.Granted)
+ return Array.Empty();
+
+ var contacts = await Contacts.Default.GetAllAsync();
+ if (contacts is null) return Array.Empty();
+
+ // Carry the per-contact email list as-is. A real
+ // device contact can carry several addresses (home /
+ // work / other); the UI use case ("invite / add to a
+ // circle") can then decide which address to use, or
+ // let the user pick. The platform-neutral ContactDto
+ // shape is intentionally richer than the Yavsc
+ // directory's single-Email shape — the two flows
+ // answer different questions.
+ var result = new List(contacts.Count);
+ foreach (var c in contacts)
+ {
+ var emails = ExtractEmails(c.Emails);
+ result.Add(new ContactDto(
+ c.Id,
+ c.DisplayName ?? string.Empty,
+ emails));
+ }
+ return result;
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"ContactService: {ex.Message}");
+ return Array.Empty();
+ }
+ }
+
+ private static IReadOnlyList ExtractEmails(IEnumerable? emails)
+ {
+ if (emails is null) return Array.Empty();
+ var list = new List();
+ foreach (var e in emails)
+ {
+ if (!string.IsNullOrEmpty(e.EmailAddress))
+ list.Add(e.EmailAddress);
+ }
+ return list;
+ }
+}
+#endif
diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs
new file mode 100644
index 00000000..49ca3064
--- /dev/null
+++ b/src/PostIt/PostIt/Services/IContactService.cs
@@ -0,0 +1,57 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PostIt.Services;
+
+///
+/// Abstraction over the device-local address book. Used by
+/// the "invite someone" flow to enumerate people the user
+/// already has in their phone — including people who have
+/// never heard of Yavsc.
+///
+/// Distinct from , which
+/// reads the central Yavsc user table. A device contact may
+/// not have a Yavsc account; a directory entry always does.
+/// The two are exposed as separate interfaces so a UI that
+/// needs both can take both by constructor injection and
+/// present them under separate sections (e.g. "Contacts from
+/// your phone" vs "Yavsc members").
+///
+/// Implementations live next to this file in
+/// platform-conditional source files:
+/// ContactService.Mobile.cs (ANDROID/IOS) and
+/// ContactService.Desktop.cs (everything else). On
+/// desktop the implementation is a stub that returns an
+/// empty list: the desktop has no equivalent of the mobile
+/// address book, and inviting from a desktop is a separate
+/// flow.
+///
+public interface IContactService
+{
+ ///
+ /// Read the device address book. Returns the contacts
+ /// known to the local provider; on desktop (no local
+ /// provider) this is always an empty list.
+ ///
+ Task> GetDeviceContactsAsync(CancellationToken ct = default);
+}
+
+///
+/// Platform-neutral contact DTO. Source-of-truth shape for
+/// the UI layer; concrete providers (MAUI Essentials on
+/// mobile) map to this type.
+///
+/// Emails is a list on purpose: a real device
+/// contact may carry several addresses (home / work / other).
+/// The UI use case ("invite / add to a circle") can then
+/// decide which address to use, or let the user pick. This
+/// is intentionally richer than the Yavsc directory's
+/// single-Email shape — the two flows answer different
+/// questions and shouldn't be flattened onto the same
+/// wire.
+///
+public sealed record ContactDto(
+ string Id,
+ string DisplayName,
+ IReadOnlyList Emails);
diff --git a/src/PostIt/PostIt/Services/IUserDirectory.cs b/src/PostIt/PostIt/Services/IUserDirectory.cs
new file mode 100644
index 00000000..7d4c1eb4
--- /dev/null
+++ b/src/PostIt/PostIt/Services/IUserDirectory.cs
@@ -0,0 +1,67 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PostIt.Services;
+
+///
+/// Abstraction over the central Yavsc user directory. Used by
+/// the "add to a circle" flow to find Yavsc users by display
+/// name or email.
+///
+/// Distinct from , which
+/// reads the device-local address book. A Yavsc user
+/// directory entry is always a registered account; a device
+/// contact may be anyone in the user's phone — including
+/// people who have never heard of Yavsc.
+///
+/// Implementations live next to this file in
+/// platform-conditional source files:
+/// UserDirectory.Desktop.cs and
+/// UserDirectory.Mobile.cs. Both currently delegate to
+/// UserSearchClient (the central /api/user-search
+/// endpoint); the split exists so future platform-specific
+/// sources (offline cache, directory-scoped providers) can be
+/// plugged in without disturbing the consumer.
+///
+public interface IUserDirectory
+{
+ ///
+ /// Search the directory by display name (substring) and/or
+ /// email (exact).
+ ///
+ /// Substring filter on the user's
+ /// display name. Empty or whitespace short-circuits to an
+ /// empty list (matches the client UX of "type to search",
+ /// not "show me a directory").
+ /// Cancellation token.
+ /// A flat list of matching directory entries.
+ /// Never null; may be empty.
+ Task> SearchAsync(string query, CancellationToken ct = default);
+}
+
+///
+/// Platform-neutral summary of a Yavsc directory entry. Mirrors
+/// the wire shape of /api/user-search (see
+/// UserSearchResultDto) but expressed in terms that
+/// don't leak transport concerns.
+///
+/// Kept as a record on purpose: directory entries are
+/// immutable snapshots from the server, so structural equality
+/// makes "did the user already pick this one?" trivial.
+///
+public sealed record UserSummary(
+ string Id,
+ string UserName,
+ string? FullName,
+ string? Avatar,
+ string? Email)
+{
+ ///
+ /// Convenience for "what to show in a picker". Falls back
+ /// to when
+ /// is null or empty.
+ ///
+ public string DisplayName =>
+ string.IsNullOrWhiteSpace(FullName) ? UserName : FullName;
+}
diff --git a/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs b/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs
new file mode 100644
index 00000000..c821bb87
--- /dev/null
+++ b/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs
@@ -0,0 +1,52 @@
+#if !ANDROID && !IOS
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Yavsc.Api.Client;
+
+namespace PostIt.Services;
+
+///
+/// Desktop implementation of .
+/// Delegates to the central /api/user-search endpoint
+/// via .
+///
+/// The desktop has no device-local address book, so the
+/// "add to a circle" flow on desktop is Yavsc-users-only.
+/// Inviting someone who doesn't have a Yavsc account from
+/// desktop is a separate feature (manual email entry +
+/// invitation endpoint) and lives outside this interface.
+///
+public sealed class UserDirectory : IUserDirectory
+{
+ private readonly UserSearchClient _client;
+
+ public UserDirectory(UserSearchClient client)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ }
+
+ public async Task> SearchAsync(
+ string query, CancellationToken ct = default)
+ {
+ // UserSearchClient already short-circuits on empty
+ // queries, but do it here too so the contract is
+ // obvious to anyone reading IUserDirectory alone
+ // without having to chase the client wrapper.
+ if (string.IsNullOrWhiteSpace(query))
+ return Array.Empty();
+
+ var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
+ if (results is null) return Array.Empty();
+
+ return results.Select(u => new UserSummary(
+ Id: u.Id,
+ UserName: u.UserName,
+ FullName: u.FullName,
+ Avatar: u.Avatar,
+ Email: u.Email)).ToList();
+ }
+}
+#endif
diff --git a/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs b/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs
new file mode 100644
index 00000000..5cba6e4a
--- /dev/null
+++ b/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs
@@ -0,0 +1,49 @@
+#if ANDROID || IOS
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Yavsc.Api.Client;
+
+namespace PostIt.Services;
+
+///
+/// Mobile implementation of .
+/// Same backing as the desktop provider (the central
+/// /api/user-search endpoint via
+/// ) — mobile devices have the
+/// network too, and "add to a circle" needs the same directory
+/// regardless of platform.
+///
+/// The split exists so a future mobile-only provider
+/// (offline cache, device-local mirror of the user's own
+/// circles) can be plugged in without touching consumers.
+///
+public sealed class UserDirectory : IUserDirectory
+{
+ private readonly UserSearchClient _client;
+
+ public UserDirectory(UserSearchClient client)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ }
+
+ public async Task> SearchAsync(
+ string query, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ return Array.Empty();
+
+ var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
+ if (results is null) return Array.Empty();
+
+ return results.Select(u => new UserSummary(
+ Id: u.Id,
+ UserName: u.UserName,
+ FullName: u.FullName,
+ Avatar: u.Avatar,
+ Email: u.Email)).ToList();
+ }
+}
+#endif
diff --git a/src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs b/src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs
new file mode 100644
index 00000000..d77f50e7
--- /dev/null
+++ b/src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs
@@ -0,0 +1,23 @@
+namespace Yavsc.Api.Client.Dtos;
+
+///
+/// Wire format for GET /api/user-search.
+///
+/// Mirrors the server-side
+/// Yavsc.Blogs.Controllers.UserSearchResultDto but stops
+/// short of any entity navigation properties. Only the fields
+/// a client address book needs (id, name, avatar, email) are
+/// included.
+///
+/// Field names match the JSON the server emits (camelCase
+/// via the default policy), so
+/// no [JsonPropertyName] attributes are required.
+///
+public sealed class UserSearchResultDto
+{
+ public string Id { get; set; } = string.Empty;
+ public string UserName { get; set; } = string.Empty;
+ public string? FullName { get; set; }
+ public string? Avatar { get; set; }
+ public string? Email { get; set; }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Api.Client/UserSearchClient.cs b/src/Yavsc.Api.Client/UserSearchClient.cs
new file mode 100644
index 00000000..d1aef6be
--- /dev/null
+++ b/src/Yavsc.Api.Client/UserSearchClient.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Yavsc.Api.Client.Dtos;
+
+namespace Yavsc.Api.Client;
+
+///
+/// HTTP client for /api/user-search on the Yavsc Blogs
+/// server. Used by client-side address books (PostIt.Desktop,
+/// future PostIt.Browser CLI, …) to look up Yavsc users by
+/// display name or email.
+///
+/// The server scopes every endpoint to the authenticated
+/// caller; any authenticated user can search the user table of
+/// the instance. There is no per-user filtering on the response
+/// side — this is by design on single-tenant deployments
+/// (closed community). Multi-tenant deployments should gate
+/// this controller behind a tenant-scoped policy before
+/// exposing it; see the server-side
+/// UserSearchApiController doc for details.
+///
+public sealed class UserSearchClient
+{
+ private const string Path = "user-search";
+
+ private readonly IYavscApiClient _api;
+
+ public UserSearchClient(IYavscApiClient api, string blogsBaseAddress)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ if (string.IsNullOrEmpty(blogsBaseAddress))
+ throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
+
+ if (api.Http.BaseAddress is null)
+ api.Http.BaseAddress = new Uri(blogsBaseAddress);
+ }
+
+ ///
+ /// Search users by display name (substring) or email (exact).
+ ///
+ /// Substring filter on FullName or
+ /// UserName. Empty or null returns an empty list (the server
+ /// would return all users, which we don't want by
+ /// default).
+ /// Optional exact-match filter on
+ /// Email.
+ /// Maximum results, capped at 100.
+ /// Default 25.
+ public Task> SearchAsync(
+ string? query = null,
+ string? email = null,
+ int take = 25,
+ CancellationToken ct = default)
+ {
+ // Match the server's contract: at least one filter is
+ // expected. The server doesn't enforce this (an empty
+ // query + empty email returns the first `take` users
+ // alphabetically), but the address-book UX is "type
+ // something to search", so we short-circuit empty
+ // queries client-side.
+ if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(email))
+ return Task.FromResult(new List());
+
+ var qs = new List();
+ if (!string.IsNullOrWhiteSpace(query))
+ qs.Add($"q={Uri.EscapeDataString(query)}");
+ if (!string.IsNullOrWhiteSpace(email))
+ qs.Add($"e={Uri.EscapeDataString(email)}");
+ qs.Add($"take={Math.Clamp(take, 1, 100)}");
+
+ return _api.CallAsync>(
+ HttpMethod.Get,
+ $"{Path}?{string.Join('&', qs)}",
+ ct: ct);
+ }
+}
\ No newline at end of file