diff --git a/CLAUDE.md b/CLAUDE.md
index 6dc7e44..56b3863 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -264,14 +264,16 @@ supplies:
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
+ A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
+ (`/api/v2/search`, never resolving), once typing pauses.
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
keeps a remote post's earlier versions from its edits and from Pleroma's `formerRepresentations`).
- **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds
and adds what was found, in the thread's order.
-- **Interaction policies** (GoToSocial's): a reply or boost whose target's author must approve it shows so on its card
- (`privapub.approval`: pending, then nothing once approved, or rejected); one the policy shuts out is refused by
- PrivaPub (422) and shown as an error.
+- **Interaction policies** (GoToSocial's, and FEP-5624's `canReply` on a PeerTube video whose comments are moderated): a
+ reply or boost whose target's author must approve it shows so on its card (`privapub.approval`: pending, then nothing
+ once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error.
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
the client then signs out), the persona's profile (`ProfileEditor`:
diff --git a/Components/Composer.razor b/Components/Composer.razor
index 931ef2d..78ab2c9 100644
--- a/Components/Composer.razor
+++ b/Components/Composer.razor
@@ -1,3 +1,4 @@
+@using System.Text.RegularExpressions
@inherits LocalizableComponentBase
@* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a
@@ -17,11 +18,28 @@
}
-
@ContentLength/@ComposeForm.MaxContentLength
+ @if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
+ {
+
+ @foreach (var account in suggestedAccounts)
+ {
+
+ @* alt="": the handle is next to it. *@
+
+ @("@" + account.Acct)
+
+ }
+ @foreach (var tag in suggestedTags)
+ {
+
#@tag.Name
+ }
+
+ }
@if (Quoting is not null)
{
@@ -215,6 +233,7 @@
@code {
[Inject] StatusService StatusService { get; set; }
+ [Inject] AccountService AccountService { get; set; }
[Inject] ILoggingService Logger { get; set; }
[Inject] IJSRuntime JS { get; set; }
@@ -231,11 +250,51 @@
ComposeForm form = new();
List fileErrors = [];
+ // what the words end with while a mention or a hashtag is being written: its sign and what follows it
+ static readonly Regex Typing = new(@"(?:^|\s)([@#])([\p{L}\p{N}_][\p{L}\p{N}_.\-]*(?:@[\p{L}\p{N}.\-]*)?)$", RegexOptions.Compiled);
+ List suggestedAccounts = [];
+ List suggestedTags = [];
+ // the newest lookup: an older one's answer comes too late to show
+ int suggesting;
string error;
bool isPublishing;
int ContentLength => form.Content?.Length ?? 0;
+ // what is being written, once typing pauses, among the accounts and hashtags this server knows
+ async Task Typed()
+ {
+ var typed = Typing.Match(form.Content ?? "");
+ var lookup = ++suggesting;
+ if (!typed.Success || typed.Groups[2].Length < 2)
+ {
+ suggestedAccounts = [];
+ suggestedTags = [];
+ return;
+ }
+ await Task.Delay(300);
+ if (lookup != suggesting)
+ return;
+ var hashtags = typed.Groups[1].Value == "#";
+ var found = await AccountService.Suggest(typed.Groups[2].Value, hashtags);
+ if (lookup != suggesting)
+ return;
+ suggestedAccounts = hashtags ? [] : [.. found.Accounts.Take(5)];
+ suggestedTags = hashtags ? [.. found.Hashtags.Take(5)] : [];
+ }
+
+ // the mention or hashtag being written, written out
+ void Complete(string written)
+ {
+ var typed = Typing.Match(form.Content ?? "");
+ suggesting++;
+ suggestedAccounts = [];
+ suggestedTags = [];
+ if (!typed.Success)
+ return;
+ form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
+ }
+
string Placeholder => Editing is not null ? Localizer["Edit your post"]
: Quoting is not null ? Localizer["Say something about it"]
: InReplyTo is null ? Localizer["What's happening?"]
diff --git a/Services/AccountService.cs b/Services/AccountService.cs
index 0ca9d04..bc91b83 100644
--- a/Services/AccountService.cs
+++ b/Services/AccountService.cs
@@ -193,6 +193,20 @@ namespace decePubClient.Services
return new SearchResults();
}
+ /// Accounts or hashtags this server already knows that match what is being typed, for the composer: nothing
+ /// is fetched from elsewhere.
+ public async Task Suggest(string typed, bool hashtags, CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.Get(APIs.Mastodon.GET_Search,
+ [$"q={Uri.EscapeDataString(typed)}", $"type={(hashtags ? "hashtags" : "accounts")}", "resolve=false", "limit=5"],
+ cancellationToken: cancellationToken);
+ if (response.IsSuccessStatusCode)
+ return await response.MastodonReadFromJsonAsync(cancellationToken) ?? new SearchResults();
+
+ await LogFailure(response, nameof(Suggest), cancellationToken);
+ return new SearchResults();
+ }
+
/// Saves the persona's profile; Data is its account as PrivaPub now describes it (with its Source).
public async Task UpdateProfile(ProfileForm form, CancellationToken cancellationToken = default)
{
diff --git a/tests/e2e/Actions/ComposerTests.cs b/tests/e2e/Actions/ComposerTests.cs
index 0cd92f1..ff2adf4 100644
--- a/tests/e2e/Actions/ComposerTests.cs
+++ b/tests/e2e/Actions/ComposerTests.cs
@@ -59,6 +59,29 @@ namespace decePubClient.E2E.Actions
}
}
+ // a mention being written is offered the accounts PrivaPub knows, and a click writes the whole handle out
+ [Fact]
+ public async Task A_mention_being_written_is_completed_from_the_accounts_the_server_knows()
+ {
+ var personas = Pasture.Personas();
+ Assert.SkipWhen(personas.Count < 2, "the town has one persona");
+ var (author, other) = (personas[0], personas[1]);
+ var acct = (await PersonaApi.Call(other, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue();
+ await using var session = await SignIn.As(browser, author);
+ var page = session.Page;
+ await Results.Cell("client.action.mention-suggestion", "privapub", author.UserName, async () =>
+ {
+ await OpenHome(page);
+ await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
+ var box = page.Locator("textarea[data-compose]").First;
+ await box.FillAsync("hello @" + acct[..Math.Min(4, acct.Length)]);
+ await page.Locator($"[data-suggestions] [data-suggestion='@{acct}']").ClickAsync(new() { Timeout = 15000 });
+ await Assertions.Expect(box).ToHaveValueAsync($"hello @{acct} ");
+ await Assertions.Expect(page.Locator("[data-suggestions]")).ToHaveCountAsync(0);
+ await session.AssertGuards();
+ }, page);
+ }
+
[Fact]
public async Task The_personas_own_post_is_edited_and_quoted_from_its_card()
{