From 04a31709a2d562c32134134b079cdfd5ae2dc16f Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 13:22:54 +0100 Subject: [PATCH] refactor(postit): split IContactService from IUserDirectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- src/PostIt/PostIt/App.axaml.cs | 4 +- .../PostIt/Services/ContactService.Desktop.cs | 76 +++++-------------- .../PostIt/Services/ContactService.Mobile.cs | 65 ++++++++-------- src/PostIt/PostIt/Services/IContactService.cs | 74 +++++++++--------- src/PostIt/PostIt/Services/IUserDirectory.cs | 67 ++++++++++++++++ .../PostIt/Services/UserDirectory.Desktop.cs | 52 +++++++++++++ .../PostIt/Services/UserDirectory.Mobile.cs | 49 ++++++++++++ 7 files changed, 259 insertions(+), 128 deletions(-) create mode 100644 src/PostIt/PostIt/Services/IUserDirectory.cs create mode 100644 src/PostIt/PostIt/Services/UserDirectory.Desktop.cs create mode 100644 src/PostIt/PostIt/Services/UserDirectory.Mobile.cs diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index c5ab68e2..6f93edf9 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -60,7 +60,8 @@ public partial class App : Application 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(userSearchClient); + var contactService = new ContactService(); + var userDirectory = new UserDirectory(userSearchClient); var services = new ServiceCollection(); @@ -91,6 +92,7 @@ public partial class App : Application 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 index 9da4a685..fa7d37f6 100644 --- a/src/PostIt/PostIt/Services/ContactService.Desktop.cs +++ b/src/PostIt/PostIt/Services/ContactService.Desktop.cs @@ -1,72 +1,36 @@ #if !ANDROID && !IOS using System; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; using System.Threading; using System.Threading.Tasks; -using Yavsc.Api.Client; -using Yavsc.Api.Client.Dtos; namespace PostIt.Services; /// -/// Desktop implementation of backed -/// by the central /api/user-search endpoint -/// (). +/// Desktop stub for . /// -/// Desktop has no equivalent of the mobile address book -/// (no Contacts.Default, no CardDAV out of the box), so the -/// address book is built on demand from the Yavsc user table. -/// Results are accumulated in an in-memory cache exposed as -/// ; the cache is process-lifetime only -/// — there's no persistence layer. +/// 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. /// -/// This is the consumer that closes the loop with the -/// user-search endpoint landed on the server in commit 6 -/// (b3056f1c) and the client in commit 7 -/// (6e7e0414). +/// 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 { - private readonly UserSearchClient _client; - - public ObservableCollection Contacts { get; } = new(); - - public ContactService(UserSearchClient client) - { - _client = client ?? throw new ArgumentNullException(nameof(client)); - } - public Task> GetDeviceContactsAsync(CancellationToken ct = default) - => Task.FromResult>(Contacts.ToArray()); - - public async Task SearchAsync(string query, CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(query)) - { - // Clear the cache to mirror an empty result. The - // address-book UX treats an empty query as "start - // over". - Contacts.Clear(); - return; - } - - var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); - if (results is null) return; - - // Append the search results to the cache. We don't - // de-dupe across searches — the simplest behaviour, and - // matches what users expect from a search panel ("show - // me what came back"). Callers wanting a single list - // can re-render Contacts on the next query. - foreach (var u in results) - { - Contacts.Add(new ContactDto( - Id: u.Id, - DisplayName: u.FullName ?? u.UserName, - Email: u.Email)); - } - } + => Task.FromResult>(Array.Empty()); } -#endif \ No newline at end of file +#endif diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs index d3eb8a10..8dbd134d 100644 --- a/src/PostIt/PostIt/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -1,7 +1,6 @@ #if ANDROID || IOS using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel.Communication; @@ -11,22 +10,23 @@ using Microsoft.Maui.Devices; namespace PostIt.Services; /// -/// Mobile implementation backed by MAUI Essentials Contacts.Default. +/// 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). +/// 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 the -/// PostIt.Android project). On iOS the same is required via -/// PostIt.iOS. On desktop the stub is used and this file is excluded. +/// 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 ObservableCollection Contacts { get; } = new(); - public async Task> GetDeviceContactsAsync(CancellationToken ct = default) { if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) @@ -41,18 +41,24 @@ public sealed class ContactService : IContactService var contacts = await Contacts.Default.GetAllAsync(); if (contacts is null) return Array.Empty(); - // Flatten the per-contact email list down to one - // primary email. The platform-neutral ContactDto only - // carries one; the use case ("invite / add to a - // circle") only needs one. The first non-empty entry - // wins. - Contacts.Clear(); + // 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 email = FlattenPrimaryEmail(c.Emails); - Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email)); + var emails = ExtractEmails(c.Emails); + result.Add(new ContactDto( + c.Id, + c.DisplayName ?? string.Empty, + emails)); } - return Contacts.ToArray(); + return result; } catch (Exception ex) { @@ -61,21 +67,16 @@ public sealed class ContactService : IContactService } } - public Task SearchAsync(string query, CancellationToken ct = default) - => throw new PlatformNotSupportedException( - "SearchAsync is not supported on mobile — use GetDeviceContactsAsync " + - "to load the local address book. The network search lives on the " + - "desktop service, which queries the central user-search endpoint."); - - private static string? FlattenPrimaryEmail(IEnumerable? emails) + private static IReadOnlyList ExtractEmails(IEnumerable? emails) { - if (emails is null) return null; + if (emails is null) return Array.Empty(); + var list = new List(); foreach (var e in emails) { if (!string.IsNullOrEmpty(e.EmailAddress)) - return e.EmailAddress; + list.Add(e.EmailAddress); } - return null; + return list; } } -#endif \ No newline at end of file +#endif diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs index 4f0ba102..49ca3064 100644 --- a/src/PostIt/PostIt/Services/IContactService.cs +++ b/src/PostIt/PostIt/Services/IContactService.cs @@ -1,61 +1,57 @@ -using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; namespace PostIt.Services; /// -/// Abstraction over device contact providers (MAUI Essentials on -/// mobile, the central /api/user-search endpoint on desktop). +/// 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. /// -/// Implementations live next to this file in platform-conditional -/// source files: ContactService.Mobile.cs (ANDROID/IOS) and -/// ContactService.Desktop.cs (everything else). +/// 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 { /// - /// Returns the contacts known so far. On mobile this is the - /// full device address book (after permission grant); on - /// desktop this is the in-memory cache populated by previous - /// calls — empty until the user - /// has searched for something. + /// 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); - - /// - /// On desktop: hits GET /api/user-search?q=… and - /// appends matching users to the in-memory cache exposed via - /// . On mobile: throws - /// — the mobile - /// provider uses the device-local address book, not a - /// network search. - /// - Task SearchAsync(string query, CancellationToken ct = default); - - /// - /// Live view of the in-memory contact cache. UI binds to - /// this directly for a \"search results\" panel; on mobile - /// implementations this is populated eagerly by - /// . - /// - ObservableCollection Contacts { get; } } /// -/// Platform-neutral contact DTO. Source-of-truth shape for the UI -/// layer; concrete providers (MAUI Essentials on mobile, -/// UserSearchClient on desktop) map to this type. +/// Platform-neutral contact DTO. Source-of-truth shape for +/// the UI layer; concrete providers (MAUI Essentials on +/// mobile) map to this type. /// -/// Email is a single string on purpose: the central -/// search endpoint returns one email per user, and the UI use -/// case is \"pick someone to invite / add to a circle\", which -/// never needs more than one. Multi-email contacts on mobile -/// flatten to the primary address (first non-empty). +/// 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, - string? Email); \ No newline at end of file + 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