Compare commits
19 commits
da3534bd34
...
04a31709a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
04a31709a2 |
|||
| 29a66a8c30 | |||
|
dd8cb60fb4 |
|||
|
d0e0f4c175 |
|||
|
6e7e04141b |
|||
|
69a660cafb |
|||
|
a8c219e0fa |
|||
|
ab8e77279b |
|||
|
bea2e35bb4 |
|||
|
c2d55317ab |
|||
|
24fede0bd0 |
|||
|
2df364aa1e |
|||
|
|
8960ce7d93 |
||
|
|
5c20c0bc04 |
||
|
64d25bb2f1 |
|||
|
e07c536e1f |
|||
|
fd99260bc7 |
|||
|
c4695dc254 |
|||
|
4a15edb9e5 |
11 changed files with 458 additions and 0 deletions
|
|
@ -4,6 +4,12 @@
|
||||||
|
|
||||||
C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé.
|
C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé.
|
||||||
|
|
||||||
|
# Statut actuel des actions Forgejo
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
# Statut actuel des actions GitHub
|
# Statut actuel des actions GitHub
|
||||||
|
|
||||||
* [](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml)
|
* [](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageVersion Include="Material.Avalonia" Version="3.17.0" />
|
<PackageVersion Include="Material.Avalonia" Version="3.17.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||||
|
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" />
|
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ public partial class App : Application
|
||||||
var client = new BlogApiClient(api, settings.BlogsApiUrl);
|
var client = new BlogApiClient(api, settings.BlogsApiUrl);
|
||||||
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 contactService = new ContactService();
|
||||||
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
|
|
@ -87,6 +90,9 @@ public partial class App : Application
|
||||||
services.AddSingleton(client);
|
services.AddSingleton(client);
|
||||||
services.AddSingleton(circleClient);
|
services.AddSingleton(circleClient);
|
||||||
services.AddSingleton(blogAclClient);
|
services.AddSingleton(blogAclClient);
|
||||||
|
services.AddSingleton(userSearchClient);
|
||||||
|
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>();
|
||||||
|
|
|
||||||
36
src/PostIt/PostIt/Services/ContactService.Desktop.cs
Normal file
36
src/PostIt/PostIt/Services/ContactService.Desktop.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
#if !ANDROID && !IOS
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace PostIt.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Desktop stub for <see cref="IContactService"/>.
|
||||||
|
///
|
||||||
|
/// <para>The desktop has no equivalent of the mobile address
|
||||||
|
/// book (no <c>Contacts.Default</c>, no CardDAV out of the
|
||||||
|
/// box). Rather than synthesise a list from a different
|
||||||
|
/// source, this provider returns an empty list and lets the
|
||||||
|
/// UI render an honest "no local contacts on this platform"
|
||||||
|
/// message.</para>
|
||||||
|
///
|
||||||
|
/// <para>If desktop users want to invite people who aren't
|
||||||
|
/// Yavsc members, that flow goes through a separate path
|
||||||
|
/// (manual email entry + invitation endpoint) — not through
|
||||||
|
/// <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>
|
||||||
|
public sealed class ContactService : IContactService
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
|
||||||
|
}
|
||||||
|
#endif
|
||||||
82
src/PostIt/PostIt/Services/ContactService.Mobile.cs
Normal file
82
src/PostIt/PostIt/Services/ContactService.Mobile.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
#if ANDROID || IOS
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Maui.ApplicationModel.Communication;
|
||||||
|
using Microsoft.Maui.ApplicationModel;
|
||||||
|
using Microsoft.Maui.Devices;
|
||||||
|
|
||||||
|
namespace PostIt.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mobile implementation backed by MAUI Essentials
|
||||||
|
/// <c>Contacts.Default</c>.
|
||||||
|
///
|
||||||
|
/// <para>Compiled only for ANDROID and IOS. On desktop targets,
|
||||||
|
/// see <c>ContactService.Desktop.cs</c> (the stub that wins at
|
||||||
|
/// compile time).</para>
|
||||||
|
///
|
||||||
|
/// <para>Note: at runtime, this class throws
|
||||||
|
/// <c>NotImplementedInReferenceAssemblyException</c> unless
|
||||||
|
/// the host application project also references the
|
||||||
|
/// platform-specific Microsoft.Maui.Essentials implementation
|
||||||
|
/// (typically <c>PostIt.Android</c>). On iOS the same is
|
||||||
|
/// required via <c>PostIt.iOS</c>. On desktop the stub is used
|
||||||
|
/// and this file is excluded.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ContactService : IContactService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
|
||||||
|
return Array.Empty<ContactDto>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var status = await Permissions.RequestAsync<Permissions.ContactsRead>();
|
||||||
|
if (status != PermissionStatus.Granted)
|
||||||
|
return Array.Empty<ContactDto>();
|
||||||
|
|
||||||
|
var contacts = await Contacts.Default.GetAllAsync();
|
||||||
|
if (contacts is null) return Array.Empty<ContactDto>();
|
||||||
|
|
||||||
|
// Carry the per-contact email list as-is. A real
|
||||||
|
// device contact can carry several addresses (home /
|
||||||
|
// work / other); the UI use case ("invite / add to a
|
||||||
|
// circle") can then decide which address to use, or
|
||||||
|
// let the user pick. The platform-neutral ContactDto
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
var emails = ExtractEmails(c.Emails);
|
||||||
|
result.Add(new ContactDto(
|
||||||
|
c.Id,
|
||||||
|
c.DisplayName ?? string.Empty,
|
||||||
|
emails));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine($"ContactService: {ex.Message}");
|
||||||
|
return Array.Empty<ContactDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ExtractEmails(IEnumerable<EmailAddress>? emails)
|
||||||
|
{
|
||||||
|
if (emails is null) return Array.Empty<string>();
|
||||||
|
var list = new List<string>();
|
||||||
|
foreach (var e in emails)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(e.EmailAddress))
|
||||||
|
list.Add(e.EmailAddress);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
57
src/PostIt/PostIt/Services/IContactService.cs
Normal file
57
src/PostIt/PostIt/Services/IContactService.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace PostIt.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction over the device-local address book. Used by
|
||||||
|
/// the "invite someone" flow to enumerate people the user
|
||||||
|
/// already has in their phone — including people who have
|
||||||
|
/// never heard of Yavsc.
|
||||||
|
///
|
||||||
|
/// <para>Distinct from <see cref="IUserDirectory"/>, which
|
||||||
|
/// reads the central Yavsc user table. A device contact may
|
||||||
|
/// 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>
|
||||||
|
public interface IContactService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Read the device address book. Returns the contacts
|
||||||
|
/// known to the local provider; on desktop (no local
|
||||||
|
/// provider) this is always an empty list.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-neutral contact DTO. Source-of-truth shape for
|
||||||
|
/// the UI layer; concrete providers (MAUI Essentials on
|
||||||
|
/// mobile) map to this type.
|
||||||
|
///
|
||||||
|
/// <para><c>Emails</c> is a list on purpose: a real device
|
||||||
|
/// contact may carry several addresses (home / work / other).
|
||||||
|
/// The UI use case ("invite / add to a circle") can then
|
||||||
|
/// decide which address to use, or let the user pick. This
|
||||||
|
/// 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>
|
||||||
|
public sealed record ContactDto(
|
||||||
|
string Id,
|
||||||
|
string DisplayName,
|
||||||
|
IReadOnlyList<string> Emails);
|
||||||
67
src/PostIt/PostIt/Services/IUserDirectory.cs
Normal file
67
src/PostIt/PostIt/Services/IUserDirectory.cs
Normal 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;
|
||||||
|
}
|
||||||
52
src/PostIt/PostIt/Services/UserDirectory.Desktop.cs
Normal file
52
src/PostIt/PostIt/Services/UserDirectory.Desktop.cs
Normal 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
|
||||||
49
src/PostIt/PostIt/Services/UserDirectory.Mobile.cs
Normal file
49
src/PostIt/PostIt/Services/UserDirectory.Mobile.cs
Normal 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
|
||||||
23
src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs
Normal file
23
src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace Yavsc.Api.Client.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire format for <c>GET /api/user-search</c>.
|
||||||
|
///
|
||||||
|
/// <para>Mirrors the server-side
|
||||||
|
/// <c>Yavsc.Blogs.Controllers.UserSearchResultDto</c> but stops
|
||||||
|
/// short of any entity navigation properties. Only the fields
|
||||||
|
/// a client address book needs (id, name, avatar, email) are
|
||||||
|
/// included.</para>
|
||||||
|
///
|
||||||
|
/// <para>Field names match the JSON the server emits (camelCase
|
||||||
|
/// via the default <see cref="System.Text.Json"/> policy), so
|
||||||
|
/// no <c>[JsonPropertyName]</c> attributes are required.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserSearchResultDto
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public string? FullName { get; set; }
|
||||||
|
public string? Avatar { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
}
|
||||||
79
src/Yavsc.Api.Client/UserSearchClient.cs
Normal file
79
src/Yavsc.Api.Client/UserSearchClient.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Yavsc.Api.Client.Dtos;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP client for <c>/api/user-search</c> on the Yavsc Blogs
|
||||||
|
/// server. Used by client-side address books (PostIt.Desktop,
|
||||||
|
/// future PostIt.Browser CLI, …) to look up Yavsc users by
|
||||||
|
/// display name or email.
|
||||||
|
///
|
||||||
|
/// <para>The server scopes every endpoint to the authenticated
|
||||||
|
/// caller; any authenticated user can search the user table of
|
||||||
|
/// the instance. There is no per-user filtering on the response
|
||||||
|
/// side — this is by design on single-tenant deployments
|
||||||
|
/// (closed community). Multi-tenant deployments should gate
|
||||||
|
/// this controller behind a tenant-scoped policy before
|
||||||
|
/// exposing it; see the server-side
|
||||||
|
/// <c>UserSearchApiController</c> doc for details.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserSearchClient
|
||||||
|
{
|
||||||
|
private const string Path = "user-search";
|
||||||
|
|
||||||
|
private readonly IYavscApiClient _api;
|
||||||
|
|
||||||
|
public UserSearchClient(IYavscApiClient api, string blogsBaseAddress)
|
||||||
|
{
|
||||||
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
|
if (string.IsNullOrEmpty(blogsBaseAddress))
|
||||||
|
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
|
||||||
|
|
||||||
|
if (api.Http.BaseAddress is null)
|
||||||
|
api.Http.BaseAddress = new Uri(blogsBaseAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Search users by display name (substring) or email (exact).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">Substring filter on FullName or
|
||||||
|
/// UserName. Empty or null returns an empty list (the server
|
||||||
|
/// would return all users, which we don't want by
|
||||||
|
/// default).</param>
|
||||||
|
/// <param name="email">Optional exact-match filter on
|
||||||
|
/// Email.</param>
|
||||||
|
/// <param name="take">Maximum results, capped at 100.
|
||||||
|
/// Default 25.</param>
|
||||||
|
public Task<List<UserSearchResultDto>> SearchAsync(
|
||||||
|
string? query = null,
|
||||||
|
string? email = null,
|
||||||
|
int take = 25,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Match the server's contract: at least one filter is
|
||||||
|
// expected. The server doesn't enforce this (an empty
|
||||||
|
// query + empty email returns the first `take` users
|
||||||
|
// alphabetically), but the address-book UX is "type
|
||||||
|
// something to search", so we short-circuit empty
|
||||||
|
// queries client-side.
|
||||||
|
if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(email))
|
||||||
|
return Task.FromResult(new List<UserSearchResultDto>());
|
||||||
|
|
||||||
|
var qs = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
qs.Add($"q={Uri.EscapeDataString(query)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(email))
|
||||||
|
qs.Add($"e={Uri.EscapeDataString(email)}");
|
||||||
|
qs.Add($"take={Math.Clamp(take, 1, 100)}");
|
||||||
|
|
||||||
|
return _api.CallAsync<List<UserSearchResultDto>>(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"{Path}?{string.Join('&', qs)}",
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue