From d0e0f4c17520d0a483163785e4c804e6b48c0c45 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 00:36:36 +0100 Subject: [PATCH] feat(postit): wire Desktop address book to /api/user-search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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): 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) -> 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. --- src/PostIt/PostIt/App.axaml.cs | 2 + .../PostIt/Services/ContactService.Desktop.cs | 67 ++++++++++++++++--- .../PostIt/Services/ContactService.Mobile.cs | 43 ++++++++---- src/PostIt/PostIt/Services/IContactService.cs | 44 ++++++++++-- 4 files changed, 126 insertions(+), 30 deletions(-) diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index c4b81fe3..c5ab68e2 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -60,6 +60,7 @@ 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 services = new ServiceCollection(); @@ -89,6 +90,7 @@ public partial class App : Application services.AddSingleton(circleClient); services.AddSingleton(blogAclClient); services.AddSingleton(userSearchClient); + services.AddSingleton(contactService); 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 82746c49..9da4a685 100644 --- a/src/PostIt/PostIt/Services/ContactService.Desktop.cs +++ b/src/PostIt/PostIt/Services/ContactService.Desktop.cs @@ -1,27 +1,72 @@ #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 stub for IContactService. +/// Desktop implementation of backed +/// by the central /api/user-search endpoint +/// (). /// -/// On desktop targets (Linux, macOS, Windows) MAUI Essentials -/// Contacts.Default throws NotImplementedInReferenceAssemblyException, -/// so we short-circuit with an empty list rather than trying to -/// call into the portable facade at runtime. +/// 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. /// -/// Future provider plug-ins (Google Contacts API, Exchange EWS, -/// CardDAV) can either replace this stub on a per-OS basis or -/// live behind their own IContactService implementation that the -/// DI container selects by configuration. +/// 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). /// 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>(Array.Empty()); + => 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)); + } + } } -#endif +#endif \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs index 744fb9a6..d3eb8a10 100644 --- a/src/PostIt/PostIt/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -1,6 +1,7 @@ #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; @@ -24,6 +25,8 @@ namespace PostIt.Services; /// public sealed class ContactService : IContactService { + public ObservableCollection Contacts { get; } = new(); + public async Task> GetDeviceContactsAsync(CancellationToken ct = default) { if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) @@ -38,21 +41,18 @@ public sealed class ContactService : IContactService var contacts = await Contacts.Default.GetAllAsync(); if (contacts is null) return Array.Empty(); - var result = new List(); + // 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(); foreach (var c in contacts) { - var emails = new List(); - if (c.Emails is not null) - { - foreach (var e in c.Emails) - { - if (!string.IsNullOrEmpty(e.EmailAddress)) - emails.Add(e.EmailAddress); - } - } - result.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, emails)); + var email = FlattenPrimaryEmail(c.Emails); + Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email)); } - return result; + return Contacts.ToArray(); } catch (Exception ex) { @@ -60,5 +60,22 @@ public sealed class ContactService : IContactService return Array.Empty(); } } + + 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) + { + if (emails is null) return null; + foreach (var e in emails) + { + if (!string.IsNullOrEmpty(e.EmailAddress)) + return e.EmailAddress; + } + return null; + } } -#endif +#endif \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs index 8c6da44a..4f0ba102 100644 --- a/src/PostIt/PostIt/Services/IContactService.cs +++ b/src/PostIt/PostIt/Services/IContactService.cs @@ -1,13 +1,14 @@ 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, -/// future Google/Exchange/IMAP providers). +/// Abstraction over device contact providers (MAUI Essentials on +/// mobile, the central /api/user-search endpoint on desktop). /// /// Implementations live next to this file in platform-conditional /// source files: ContactService.Mobile.cs (ANDROID/IOS) and @@ -15,15 +16,46 @@ namespace PostIt.Services; /// 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. + /// 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 today, Google Contacts API later) -/// map to this type. +/// 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. +/// +/// 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). /// public sealed record ContactDto( string Id, string DisplayName, - IReadOnlyList Emails); + string? Email); \ No newline at end of file