feat/postit-acl #32
4 changed files with 126 additions and 30 deletions
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.
commit
d0e0f4c175
|
|
@ -60,6 +60,7 @@ public partial class App : Application
|
||||||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||||
|
var contactService = new ContactService(userSearchClient);
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
|
|
@ -89,6 +90,7 @@ public partial class App : Application
|
||||||
services.AddSingleton(circleClient);
|
services.AddSingleton(circleClient);
|
||||||
services.AddSingleton(blogAclClient);
|
services.AddSingleton(blogAclClient);
|
||||||
services.AddSingleton(userSearchClient);
|
services.AddSingleton(userSearchClient);
|
||||||
|
services.AddSingleton<IContactService>(contactService);
|
||||||
services.AddTransient<MainPageViewModel>();
|
services.AddTransient<MainPageViewModel>();
|
||||||
services.AddTransient<HomePageViewModel>();
|
services.AddTransient<HomePageViewModel>();
|
||||||
services.AddTransient<SignaturePageViewModel>();
|
services.AddTransient<SignaturePageViewModel>();
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,72 @@
|
||||||
#if !ANDROID && !IOS
|
#if !ANDROID && !IOS
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
using Yavsc.Api.Client.Dtos;
|
||||||
|
|
||||||
namespace PostIt.Services;
|
namespace PostIt.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Desktop stub for IContactService.
|
/// Desktop implementation of <see cref="IContactService"/> backed
|
||||||
|
/// by the central <c>/api/user-search</c> endpoint
|
||||||
|
/// (<see cref="UserSearchClient"/>).
|
||||||
///
|
///
|
||||||
/// On desktop targets (Linux, macOS, Windows) MAUI Essentials
|
/// <para>Desktop has no equivalent of the mobile address book
|
||||||
/// Contacts.Default throws NotImplementedInReferenceAssemblyException,
|
/// (no Contacts.Default, no CardDAV out of the box), so the
|
||||||
/// so we short-circuit with an empty list rather than trying to
|
/// address book is built on demand from the Yavsc user table.
|
||||||
/// call into the portable facade at runtime.
|
/// Results are accumulated in an in-memory cache exposed as
|
||||||
|
/// <see cref="Contacts"/>; the cache is process-lifetime only
|
||||||
|
/// — there's no persistence layer.</para>
|
||||||
///
|
///
|
||||||
/// Future provider plug-ins (Google Contacts API, Exchange EWS,
|
/// <para>This is the consumer that closes the loop with the
|
||||||
/// CardDAV) can either replace this stub on a per-OS basis or
|
/// user-search endpoint landed on the server in commit 6
|
||||||
/// live behind their own IContactService implementation that the
|
/// (<c>b3056f1c</c>) and the client in commit 7
|
||||||
/// DI container selects by configuration.
|
/// (<c>6e7e0414</c>).</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ContactService : IContactService
|
public sealed class ContactService : IContactService
|
||||||
{
|
{
|
||||||
|
private readonly UserSearchClient _client;
|
||||||
|
|
||||||
|
public ObservableCollection<ContactDto> Contacts { get; } = new();
|
||||||
|
|
||||||
|
public ContactService(UserSearchClient client)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
|
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
|
||||||
=> Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
|
=> Task.FromResult<IReadOnlyList<ContactDto>>(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
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#if ANDROID || IOS
|
#if ANDROID || IOS
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Maui.ApplicationModel.Communication;
|
using Microsoft.Maui.ApplicationModel.Communication;
|
||||||
|
|
@ -24,6 +25,8 @@ namespace PostIt.Services;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ContactService : IContactService
|
public sealed class ContactService : IContactService
|
||||||
{
|
{
|
||||||
|
public ObservableCollection<ContactDto> Contacts { get; } = new();
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
|
if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
|
||||||
|
|
@ -38,21 +41,18 @@ public sealed class ContactService : IContactService
|
||||||
var contacts = await Contacts.Default.GetAllAsync();
|
var contacts = await Contacts.Default.GetAllAsync();
|
||||||
if (contacts is null) return Array.Empty<ContactDto>();
|
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)
|
foreach (var c in contacts)
|
||||||
{
|
{
|
||||||
var emails = new List<string>();
|
var email = FlattenPrimaryEmail(c.Emails);
|
||||||
if (c.Emails is not null)
|
Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email));
|
||||||
{
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
return result;
|
return Contacts.ToArray();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -60,5 +60,22 @@ public sealed class ContactService : IContactService
|
||||||
return Array.Empty<ContactDto>();
|
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
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace PostIt.Services;
|
namespace PostIt.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Abstraction over device contact providers (MAUI Essentials on mobile,
|
/// Abstraction over device contact providers (MAUI Essentials on
|
||||||
/// future Google/Exchange/IMAP providers).
|
/// mobile, the central /api/user-search endpoint on desktop).
|
||||||
///
|
///
|
||||||
/// Implementations live next to this file in platform-conditional
|
/// Implementations live next to this file in platform-conditional
|
||||||
/// source files: ContactService.Mobile.cs (ANDROID/IOS) and
|
/// source files: ContactService.Mobile.cs (ANDROID/IOS) and
|
||||||
|
|
@ -15,15 +16,46 @@ namespace PostIt.Services;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IContactService
|
public interface IContactService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="SearchAsync"/> calls — empty until the user
|
||||||
|
/// has searched for something.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// On desktop: hits <c>GET /api/user-search?q=…</c> and
|
||||||
|
/// appends matching users to the in-memory cache exposed via
|
||||||
|
/// <see cref="Contacts"/>. On mobile: throws
|
||||||
|
/// <see cref="PlatformNotSupportedException"/> — the mobile
|
||||||
|
/// provider uses the device-local address book, not a
|
||||||
|
/// network search.
|
||||||
|
/// </summary>
|
||||||
|
Task SearchAsync(string query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="GetDeviceContactsAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
ObservableCollection<ContactDto> Contacts { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Platform-neutral contact DTO. Source-of-truth shape for the UI layer;
|
/// Platform-neutral contact DTO. Source-of-truth shape for the UI
|
||||||
/// concrete providers (MAUI Essentials today, Google Contacts API later)
|
/// layer; concrete providers (MAUI Essentials on mobile,
|
||||||
/// map to this type.
|
/// UserSearchClient on desktop) map to this type.
|
||||||
|
///
|
||||||
|
/// <para><c>Email</c> 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).</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ContactDto(
|
public sealed record ContactDto(
|
||||||
string Id,
|
string Id,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
IReadOnlyList<string> Emails);
|
string? Email);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue