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() {