feat(postit): wire Desktop address book to /api/user-search

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.
This commit is contained in:
Paul Schneider 2026-08-18 00:36:36 +01:00
commit d0e0f4c175
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
4 changed files with 126 additions and 30 deletions

View file

@ -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;
/// </summary>
public sealed class ContactService : IContactService
{
public ObservableCollection<ContactDto> Contacts { get; } = new();
public async Task<IReadOnlyList<ContactDto>> 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<ContactDto>();
var result = new List<ContactDto>();
// 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<string>();
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<ContactDto>();
}
}
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<EmailAddress>? emails)
{
if (emails is null) return null;
foreach (var e in emails)
{
if (!string.IsNullOrEmpty(e.EmailAddress))
return e.EmailAddress;
}
return null;
}
}
#endif
#endif