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 + +![Build and test](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/buildAndTest.yml/badge.svg) + +![Release](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/release.yml/badge.svg) + # Statut actuel des actions GitHub * [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](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