release/1.0.7 #34

Merged
notazof merged 40 commits from release/1.0.7 into main 2026-08-18 19:05:50 +01:00
4 changed files with 126 additions and 30 deletions
Showing only changes of commit d0e0f4c175 - Show all commits

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.
Paul Schneider 2026-08-18 00:36:36 +01:00
Signed by: notazof
GPG key ID: 1DD5D838E5343B06

View file

@ -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<IContactService>(contactService);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();

View file

@ -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;
/// <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
/// Contacts.Default throws NotImplementedInReferenceAssemblyException,
/// so we short-circuit with an empty list rather than trying to
/// call into the portable facade at runtime.
/// <para>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
/// <see cref="Contacts"/>; the cache is process-lifetime only
/// — there's no persistence layer.</para>
///
/// 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.
/// <para>This is the consumer that closes the loop with the
/// user-search endpoint landed on the server in commit 6
/// (<c>b3056f1c</c>) and the client in commit 7
/// (<c>6e7e0414</c>).</para>
/// </summary>
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)
=> 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

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);
var email = FlattenPrimaryEmail(c.Emails);
Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email));
}
}
result.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, emails));
}
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

View file

@ -1,13 +1,14 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// 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;
/// </summary>
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);
/// <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>
/// 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.
///
/// <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>
public sealed record ContactDto(
string Id,
string DisplayName,
IReadOnlyList<string> Emails);
string? Email);