The composer completes mentions and hashtags
A mention or hashtag being written at the end of the words is offered, once typing pauses, the accounts or tags PrivaPub already knows (/api/v2/search, never resolving); a click writes it out. e2e: a town persona's handle, begun, is offered and completed. CLAUDE.md also says that FEP-5624's moderated comments (PeerTube) show as pending on a card, as GoToSocial's policies do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
658683f816
commit
511edf3591
4 files changed
+102
-4
No files matched your search
@@ -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`:
|
||||
|
||||
@@ -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 @@
|
||||
</DInput>
|
||||
}
|
||||
<div class="relative">
|
||||
<DTextarea TValue="string" @bind-Value="form.Content" Immediate="true"
|
||||
<DTextarea TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
|
||||
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
|
||||
placeholder="@Placeholder" rows="3" data-compose />
|
||||
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||||
</div>
|
||||
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
|
||||
{
|
||||
<div class="flex flex-wrap gap-2" data-suggestions>
|
||||
@foreach (var account in suggestedAccounts)
|
||||
{
|
||||
<DButton Size="DSize.Xs" class="max-w-full" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
||||
@* alt="": the handle is next to it. *@
|
||||
<img alt="" class="h-4 w-4 flex-none rounded-full object-cover" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||
<span class="truncate">@("@" + account.Acct)</span>
|
||||
</DButton>
|
||||
}
|
||||
@foreach (var tag in suggestedTags)
|
||||
{
|
||||
<DButton Size="DSize.Xs" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@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<string> 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<Account> suggestedAccounts = [];
|
||||
List<StatusTag> 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?"]
|
||||
|
||||
@@ -193,6 +193,20 @@ namespace decePubClient.Services
|
||||
return new SearchResults();
|
||||
}
|
||||
|
||||
/// <summary>Accounts or hashtags this server already knows that match what is being typed, for the composer: nothing
|
||||
/// is fetched from elsewhere.</summary>
|
||||
public async Task<SearchResults> 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<SearchResults>(cancellationToken) ?? new SearchResults();
|
||||
|
||||
await LogFailure(response, nameof(Suggest), cancellationToken);
|
||||
return new SearchResults();
|
||||
}
|
||||
|
||||
/// <summary>Saves the persona's profile; Data is its account as PrivaPub now describes it (with its Source).</summary>
|
||||
public async Task<WebResult> UpdateProfile(ProfileForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -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<string>();
|
||||
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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user