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
7 changed files with 259 additions and 128 deletions
Showing only changes of commit 04a31709a2 - Show all commits

refactor(postit): split IContactService from IUserDirectory

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

View file

@ -60,7 +60,8 @@ 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 contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
var services = new ServiceCollection(); var services = new ServiceCollection();
@ -91,6 +92,7 @@ public partial class App : Application
services.AddSingleton(blogAclClient); services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient); services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService); services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>(); services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>(); services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>(); services.AddTransient<SignaturePageViewModel>();

View file

@ -1,72 +1,36 @@
#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 implementation of <see cref="IContactService"/> backed /// Desktop stub for <see cref="IContactService"/>.
/// by the central <c>/api/user-search</c> endpoint
/// (<see cref="UserSearchClient"/>).
/// ///
/// <para>Desktop has no equivalent of the mobile address book /// <para>The desktop has no equivalent of the mobile address
/// (no Contacts.Default, no CardDAV out of the box), so the /// book (no <c>Contacts.Default</c>, no CardDAV out of the
/// address book is built on demand from the Yavsc user table. /// box). Rather than synthesise a list from a different
/// Results are accumulated in an in-memory cache exposed as /// source, this provider returns an empty list and lets the
/// <see cref="Contacts"/>; the cache is process-lifetime only /// UI render an honest "no local contacts on this platform"
/// — there's no persistence layer.</para> /// message.</para>
/// ///
/// <para>This is the consumer that closes the loop with the /// <para>If desktop users want to invite people who aren't
/// user-search endpoint landed on the server in commit 6 /// Yavsc members, that flow goes through a separate path
/// (<c>b3056f1c</c>) and the client in commit 7 /// (manual email entry + invitation endpoint) — not through
/// (<c>6e7e0414</c>).</para> /// <see cref="IContactService"/>. Finding existing Yavsc
/// members is <see cref="IUserDirectory"/>'s job, not this
/// one's.</para>
///
/// <para>Future CardDAV / Google Contacts / Exchange
/// providers can plug in here as additional
/// <see cref="IContactService"/> implementations selected
/// from DI by configuration.</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>>(Contacts.ToArray()); => Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
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

View file

@ -1,7 +1,6 @@
#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;
@ -11,22 +10,23 @@ using Microsoft.Maui.Devices;
namespace PostIt.Services; namespace PostIt.Services;
/// <summary> /// <summary>
/// Mobile implementation backed by MAUI Essentials Contacts.Default. /// Mobile implementation backed by MAUI Essentials
/// <c>Contacts.Default</c>.
/// ///
/// Compiled only for ANDROID and IOS. On desktop targets, see /// <para>Compiled only for ANDROID and IOS. On desktop targets,
/// ContactService.Desktop.cs (the stub that wins at compile time). /// see <c>ContactService.Desktop.cs</c> (the stub that wins at
/// compile time).</para>
/// ///
/// Note: at runtime, this class throws /// <para>Note: at runtime, this class throws
/// NotImplementedInReferenceAssemblyException unless the host /// <c>NotImplementedInReferenceAssemblyException</c> unless
/// application project also references the platform-specific /// the host application project also references the
/// Microsoft.Maui.Essentials implementation (typically the /// platform-specific Microsoft.Maui.Essentials implementation
/// PostIt.Android project). On iOS the same is required via /// (typically <c>PostIt.Android</c>). On iOS the same is
/// PostIt.iOS. On desktop the stub is used and this file is excluded. /// required via <c>PostIt.iOS</c>. On desktop the stub is used
/// and this file is excluded.</para>
/// </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)
@ -41,18 +41,24 @@ 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>();
// Flatten the per-contact email list down to one // Carry the per-contact email list as-is. A real
// primary email. The platform-neutral ContactDto only // device contact can carry several addresses (home /
// carries one; the use case ("invite / add to a // work / other); the UI use case ("invite / add to a
// circle") only needs one. The first non-empty entry // circle") can then decide which address to use, or
// wins. // let the user pick. The platform-neutral ContactDto
Contacts.Clear(); // shape is intentionally richer than the Yavsc
// directory's single-Email shape — the two flows
// answer different questions.
var result = new List<ContactDto>(contacts.Count);
foreach (var c in contacts) foreach (var c in contacts)
{ {
var email = FlattenPrimaryEmail(c.Emails); var emails = ExtractEmails(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 Contacts.ToArray(); return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -61,21 +67,16 @@ public sealed class ContactService : IContactService
} }
} }
public Task SearchAsync(string query, CancellationToken ct = default) private static IReadOnlyList<string> ExtractEmails(IEnumerable<EmailAddress>? emails)
=> 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; if (emails is null) return Array.Empty<string>();
var list = new List<string>();
foreach (var e in emails) foreach (var e in emails)
{ {
if (!string.IsNullOrEmpty(e.EmailAddress)) if (!string.IsNullOrEmpty(e.EmailAddress))
return e.EmailAddress; list.Add(e.EmailAddress);
} }
return null; return list;
} }
} }
#endif #endif

View file

@ -1,61 +1,57 @@
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 /// Abstraction over the device-local address book. Used by
/// mobile, the central /api/user-search endpoint on desktop). /// 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 /// <para>Distinct from <see cref="IUserDirectory"/>, which
/// source files: ContactService.Mobile.cs (ANDROID/IOS) and /// reads the central Yavsc user table. A device contact may
/// ContactService.Desktop.cs (everything else). /// 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").</para>
///
/// <para>Implementations live next to this file in
/// platform-conditional source files:
/// <c>ContactService.Mobile.cs</c> (ANDROID/IOS) and
/// <c>ContactService.Desktop.cs</c> (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.</para>
/// </summary> /// </summary>
public interface IContactService public interface IContactService
{ {
/// <summary> /// <summary>
/// Returns the contacts known so far. On mobile this is the /// Read the device address book. Returns the contacts
/// full device address book (after permission grant); on /// known to the local provider; on desktop (no local
/// desktop this is the in-memory cache populated by previous /// provider) this is always an empty list.
/// <see cref="SearchAsync"/> calls — empty until the user
/// has searched for something.
/// </summary> /// </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 /// Platform-neutral contact DTO. Source-of-truth shape for
/// layer; concrete providers (MAUI Essentials on mobile, /// the UI layer; concrete providers (MAUI Essentials on
/// UserSearchClient on desktop) map to this type. /// mobile) map to this type.
/// ///
/// <para><c>Email</c> is a single string on purpose: the central /// <para><c>Emails</c> is a list on purpose: a real device
/// search endpoint returns one email per user, and the UI use /// contact may carry several addresses (home / work / other).
/// case is \"pick someone to invite / add to a circle\", which /// The UI use case ("invite / add to a circle") can then
/// never needs more than one. Multi-email contacts on mobile /// decide which address to use, or let the user pick. This
/// flatten to the primary address (first non-empty).</para> /// is intentionally richer than the Yavsc directory's
/// single-<c>Email</c> shape — the two flows answer different
/// questions and shouldn't be flattened onto the same
/// wire.</para>
/// </summary> /// </summary>
public sealed record ContactDto( public sealed record ContactDto(
string Id, string Id,
string DisplayName, string DisplayName,
string? Email); IReadOnlyList<string> Emails);

View file

@ -0,0 +1,67 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Abstraction over the central Yavsc user directory. Used by
/// the "add to a circle" flow to find Yavsc users by display
/// name or email.
///
/// <para>Distinct from <see cref="IContactService"/>, 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.</para>
///
/// <para>Implementations live next to this file in
/// platform-conditional source files:
/// <c>UserDirectory.Desktop.cs</c> and
/// <c>UserDirectory.Mobile.cs</c>. Both currently delegate to
/// <c>UserSearchClient</c> (the central <c>/api/user-search</c>
/// endpoint); the split exists so future platform-specific
/// sources (offline cache, directory-scoped providers) can be
/// plugged in without disturbing the consumer.</para>
/// </summary>
public interface IUserDirectory
{
/// <summary>
/// Search the directory by display name (substring) and/or
/// email (exact).
/// </summary>
/// <param name="query">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").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A flat list of matching directory entries.
/// Never null; may be empty.</returns>
Task<IReadOnlyList<UserSummary>> SearchAsync(string query, CancellationToken ct = default);
}
/// <summary>
/// Platform-neutral summary of a Yavsc directory entry. Mirrors
/// the wire shape of <c>/api/user-search</c> (see
/// <c>UserSearchResultDto</c>) but expressed in terms that
/// don't leak transport concerns.
///
/// <para>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.</para>
/// </summary>
public sealed record UserSummary(
string Id,
string UserName,
string? FullName,
string? Avatar,
string? Email)
{
/// <summary>
/// Convenience for "what to show in a picker". Falls back
/// to <see cref="UserName"/> when <see cref="FullName"/>
/// is null or empty.
/// </summary>
public string DisplayName =>
string.IsNullOrWhiteSpace(FullName) ? UserName : FullName;
}

View file

@ -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;
/// <summary>
/// Desktop implementation of <see cref="IUserDirectory"/>.
/// Delegates to the central <c>/api/user-search</c> endpoint
/// via <see cref="UserSearchClient"/>.
///
/// <para>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.</para>
/// </summary>
public sealed class UserDirectory : IUserDirectory
{
private readonly UserSearchClient _client;
public UserDirectory(UserSearchClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public async Task<IReadOnlyList<UserSummary>> 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<UserSummary>();
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
if (results is null) return Array.Empty<UserSummary>();
return results.Select(u => new UserSummary(
Id: u.Id,
UserName: u.UserName,
FullName: u.FullName,
Avatar: u.Avatar,
Email: u.Email)).ToList();
}
}
#endif

View file

@ -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;
/// <summary>
/// Mobile implementation of <see cref="IUserDirectory"/>.
/// Same backing as the desktop provider (the central
/// <c>/api/user-search</c> endpoint via
/// <see cref="UserSearchClient"/>) — mobile devices have the
/// network too, and "add to a circle" needs the same directory
/// regardless of platform.
///
/// <para>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.</para>
/// </summary>
public sealed class UserDirectory : IUserDirectory
{
private readonly UserSearchClient _client;
public UserDirectory(UserSearchClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public async Task<IReadOnlyList<UserSummary>> SearchAsync(
string query, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(query))
return Array.Empty<UserSummary>();
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
if (results is null) return Array.Empty<UserSummary>();
return results.Select(u => new UserSummary(
Id: u.Id,
UserName: u.UserName,
FullName: u.FullName,
Avatar: u.Avatar,
Email: u.Email)).ToList();
}
}
#endif